Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh c70db4157a refactor(v2): migrate get_pipewire_sinks to v2
Path C: legacy CPAL output-sink enumeration was inlined directly in
commands/playback.rs (cfg-gated Linux + non-Linux variants), with
no equivalent in any domain crate. Extract a domain function
`qbz_audio::output_sinks::list_output_sinks` that returns the same
{name, description, volume, is_default} shape (mirrored as the new
public `OutputSinkInfo` struct) for both Linux and non-Linux
platforms, preserving the original logging cadence and the rule
that name == CPAL device name (so saved IDs reopen correctly).

Add v2_get_pipewire_sinks in commands_v2/audio.rs that calls into
qbz_audio directly — no `crate::commands::*` delegation. Updates the
two frontend call sites (AudioOutputBadges.loadStatus and
SettingsView.loadAudioDevices). Legacy command in
commands/playback.rs is preserved untouched; the audio-protected
files (pipewire_backend.rs, audio_settings.rs, player init_device)
are not modified.

cc
2026-04-26 12:03:28 -06:00
vicrodh 1abb57bd7f refactor(v2): migrate get_audio_output_status to v2
Path B: legacy command read state.player.state.current_device() and
state.player.state.is_playing() — both methods already exist on the
qbz-player Player.state in the V2 architecture. No domain extraction
needed; the v2 command reads them via CoreBridge.player() like the
existing v2_get_hardware_audio_status does.

Adds v2_get_audio_output_status returning V2AudioOutputStatus
(serialized to the same {device_name, is_playing} shape so the
AudioOutputBadges frontend type is unchanged). Updates both
AudioOutputBadges call sites (initial loadStatus + the 1s polling
interval). The legacy command in commands/playback.rs is preserved
untouched.

cc
2026-04-26 12:00:15 -06:00
vicrodh eb17cc6d22 refactor(v2): migrate get_releases_grid to v2
Path C: legacy command called client.get_releases_grid for the
artist detail view's release-type pagination grid. No existing
v2 equivalent, so add v2_get_releases_grid via QbzCore ->
QobuzClient::get_releases_grid directly.

cc
2026-04-26 11:51:33 -06:00
vicrodh 7d89ae77ef refactor(v2): migrate get_artist_tracks to v2
Path C: legacy command called client.get_artist_tracks (the
/artist/get?extra=tracks path) and returned a TracksContainer.
No existing v2 equivalent, so add v2_get_artist_tracks via
QbzCore -> QobuzClient::get_artist_tracks directly.

cc
2026-04-26 11:48:29 -06:00
vicrodh 0bc033b324 refactor(v2): migrate get_artist_detail to v2
Path C: legacy command called client.get_artist_detail with the
hard-coded mobile defaults of limit=1000, offset=0 to populate the
suggestions panel's playlists and tracks_appears_on. No close v2
equivalent existed (v2_get_artist_with_albums uses
extra=albums only), so add v2_get_artist_detail that goes through
QbzCore -> QobuzClient::get_artist_detail directly.

cc
2026-04-26 11:45:47 -06:00
vicrodh 1dcb7765d3 refactor(v2): migrate get_artist_albums to v2
Path C: legacy command projected `Artist.albums` from
client.get_artist_with_pagination(with_albums=true). The existing
v2_get_artist_with_albums returns the full Artist envelope, so
add a dedicated v2_get_artist_albums in QbzCore that performs the
same projection and exposes only ArtistAlbums to match what the
two callsites destructure.

cc
2026-04-26 11:43:23 -06:00
vicrodh a127de0f32 refactor(v2): migrate get_artist_basic to v2
Path A: existing v2_get_artist already returns Artist via
QbzCore::get_artist which delegates to client.get_artist_basic;
shape matches the legacy command exactly so the frontend just
swaps the invoke name.

cc
2026-04-26 11:37:00 -06:00
vicrodh 8ef6f9f02c refactor(v2): migrate get_flatpak_help_text to v2
Add v2_get_flatpak_help_text as a thin wrapper over the existing
flatpak::get_flatpak_guidance(). Update the frontend invoke in
SettingsView.svelte to use the v2 name. Legacy command remains for
now.

Audit: legacy_callsites 12 -> 11.

cc
2026-04-26 11:23:40 -06:00
vicrodh a393d98218 refactor(v2): migrate detect_desktop_theme to v2
Add v2_detect_desktop_theme as a thin wrapper over the existing
desktop_theme::detect_desktop_theme(). Update the frontend invoke in
windowControlsStore.ts to use the v2 name. Legacy command remains
for now.

Audit: legacy_callsites 13 -> 12.

cc
2026-04-26 11:21:34 -06:00
vicrodh 57c5c56bcb refactor(v2): migrate get_qobuz_tos_accepted to v2
Add v2_get_qobuz_tos_accepted as a thin wrapper over the existing
config::legal_settings::get_qobuz_tos_accepted. Update the frontend
invoke in qobuzLegalStore.ts to use the v2 name. Legacy command
remains for now.

Audit: legacy_callsites 14 -> 13.

cc
2026-04-26 11:20:11 -06:00
vicrodh 120e3fb827 refactor(v2): migrate has_snap_welcome_been_shown to v2
Add v2_has_snap_welcome_been_shown as a thin wrapper over the
existing UpdatesStore::has_snap_welcome_been_shown(). Update the
frontend invoke in updatesService.ts to use the v2 name. Legacy
command remains for now.

Audit: legacy_callsites 15 -> 14.

cc
2026-04-26 11:18:54 -06:00
vicrodh 0fdb539044 refactor(v2): migrate has_flatpak_welcome_been_shown to v2
Add v2_has_flatpak_welcome_been_shown as a thin wrapper over the
existing UpdatesStore::has_flatpak_welcome_been_shown(). Update the
frontend invoke in updatesService.ts to use the v2 name. Legacy
command remains for now.

Audit: legacy_callsites 16 -> 15.

cc
2026-04-26 11:17:32 -06:00
vicrodh b05edc08b7 Merge bugfix/internal/playlist-custom-order into pre-release 2026-04-26 10:24:26 -06:00
vicrodh 6ba53f40b1 Merge bugfix/internal/qconnect-cleanup into pre-release 2026-04-26 10:24:26 -06:00
vicrodh d46b0c870a fix(qconnect): skip cursor align when local is active renderer (#316)
When QConnect is on AND the local app is the active renderer, every
local next/play_index round-trips back through the Qobuz cloud as a
SET_STATE echo. apply_renderer_command_to_corebridge unconditionally
called align_corebridge_queue_cursor, which races the user's already-
advanced local cursor and triggers a second play_index, pushing a
duplicate index onto QueueManager.history. That is the duplicates half
of issue #316.

Mirror the gating that sync_active_renderer_projection already uses:
align the CoreBridge cursor only when local is NOT the active renderer.
The existing peer-driven flow (peer is active renderer, echoes arrive
to drive our cursor + history) is unchanged.

cc
2026-04-25 21:37:38 -06:00
vicrodh 9db79ecd09 fix(queue): preserve history across set_queue when tracks survive (#316)
set_queue and set_queue_with_order unconditionally cleared history, which
wiped legitimate plays whenever QConnect echoed a queue version bump
(reorder, shuffle toggle, peer-driven snapshot) without changing track
identity. That is the missing-tracks half of issue #316.

Instead, remap history entries by track id: each pre-existing index is
resolved to its track id via the old tracks list, then looked up in the
new tracks list. Entries whose track id is no longer present are dropped.
When the new queue has zero overlap with the old, history clears
naturally; when the queue is a pure reorder, history survives intact.

Six unit tests cover pure reorder, additions, partial removals,
disjoint replacement, and verify the remapped indices (not just rendered
ids) point at the right new tracks.

cc
2026-04-25 21:35:49 -06:00
vicrodh 97ab63fd9b refactor(qconnect): simplify lifecycle_state JSON serialization
Drop the to_string->from_str round-trip in favor of a direct to_value
call. Same fallback behavior on serializer error.

cc
2026-04-25 21:16:37 -06:00
vicrodh 694f1a1044 refactor(qconnect): rename NowPlayingBar prop to isQobuzConnectToggleOn
Previously the prop was named isQobuzConnectConnected, which suggested
transport-up semantics, but after 23658b05 it carries the user-intent
toggle state (true during Connecting and Reconnecting too). Rename to
match what the value actually means, removing the chance of a future
reader misreading the prop as a "safe to send commands" gate.

No behavior change.

Refs: #358

cc
2026-04-25 21:15:26 -06:00
vicrodh 47afe9037b fix(qconnect): reset lifecycle to Off when transport connect fails
Previously, an Err from app.connect() would propagate via ? while
lifecycle_state remained at Connecting and runtime was None, leaving
the frontend toggle stuck "on" (because isQconnectToggleOn treats
Connecting as on) with no live runtime. This recreated the exact
user-visible bug 23658b05 was trying to fix, just triggered from a
different upstream failure.

Convert the ? propagation into explicit error handling that resets
lifecycle_state = Off and records last_error before returning Err,
so the toggle correctly flips off and a subsequent connect() starts
fresh.

Refs: #358

cc
2026-04-25 21:14:04 -06:00
vicrodh 23658b053c fix(qconnect): decouple toggle state from transport so user can disable a stuck reconnect
When the reconnect loop is stuck waiting for a SESSION_STATE that never
comes (the actual symptom of issue #358), the runtime is alive but
`transport_connected` is false. The UI used to read its on/off toggle
from `transport_connected`, so the badge appeared OFF while the backend
loop kept retrying. Worse: clicking the toggle in that state called
`connect()`, which rejected with "QConnect service is already running".
The user had no way to disable QConnect from the UI — only to "turn it
on more", which failed.

Backend (`qconnect_service.rs`):

- Add `QconnectLifecycleState` enum (Off / Connecting / Connected /
  Reconnecting / Exhausted) and surface it on
  `QconnectConnectionStatus::state`. Driven from the spawned transport
  event loop via a new `update_lifecycle_state_if_running` helper which
  refuses to overwrite terminal states (Off / Exhausted) with late
  Reconnecting / Connected events from the broadcast queue.
- Make `connect()` idempotent: if a runtime already exists (alive but
  reconnecting), return current status instead of erroring. The toggle
  is the single user-visible control; the previous "already running"
  error was unactionable from the UI.
- Auto-stop on `MaxReconnectAttemptsExceeded`: take the runtime out,
  set state=Exhausted, set last_error to the cloud reason, and emit a
  `qconnect:status_changed` event so the frontend reflects the new
  state without waiting for its 5 s poll. The transport loop has
  already exited at this point, so a fresh user-initiated connect()
  cleanly starts a new runtime.
- `disconnect()` now forces lifecycle to Off unconditionally and tears
  down the runtime (already worked, the transport's `shutdown_tx`
  watch breaks `handle_reconnect_delay` mid-sleep), satisfying the
  "disable QConnect must always succeed" requirement.

Frontend (`qconnectRuntime.ts`, `+page.svelte`):

- Add `QconnectLifecycleState` mirror type and `isQconnectToggleOn()`
  helper.
- Split state in the page: `isQobuzConnectConnected` keeps its old
  semantics (`transport_connected`) and gates send-command paths
  (volume reports, position reports) so we don't fire commands while
  the WS is down. New `isQobuzConnectToggleOn` reflects the
  user-facing on/off — true even during Reconnecting/Connecting — and
  is what the badge component and the toggle handler now read.
- Listen for the new `qconnect:status_changed` event so the toggle
  reflects auto-stop (Exhausted) and other transitions immediately
  instead of after the 5 s status poll.

Refs #358

cc
2026-04-25 21:02:30 -06:00
vicrodh d74332514d fix(qconnect-transport-ws): decode MSG_TYPE_ERROR frame and surface code+descr
The MSG_TYPE_ERROR (cloud_type=9) arm previously logged only the payload
byte count, so when Qobuz cloud rejected a session (zombie session,
conflicting device, expired JWT, ...) we had no signal beyond an opaque
"bytes=13" TransportError. That hid the actual cause of the 2s flicker
loop seen after the app sat minimized for 1-2 hours (issue #358).

The qws.proto ErrorMessage (messageTypeId=9) carries:
  uint32 msg_id   = 1
  uint64 msg_date = 2
  uint32 code     = 3
  string descr    = 4

Decode it via a local QwsErrorMessage prost struct, log a warn line with
the decoded code+descr, and emit a new TransportEvent::CloudError so
qconnect-app and qconnect_service can react. qconnect_service forwards
it as a "qconnect:cloud_error" diagnostic (level=warning) and logs it.
qconnect-app treats it as a no-op forwarder for now, matching the
existing pattern for other transport-only events. Decode failures fall
back to the previous TransportError path under a distinct stage name so
they are still visible.

Adds a tokio test that builds a real MSG_TYPE_ERROR frame and asserts
the transport emits CloudError (not the legacy TransportError).

Refs #358

cc
2026-04-25 20:44:46 -06:00
vicrodh ecc27bbcaa fix(qconnect-transport-ws): reset backoff only on SESSION_STATE, cap reconnect attempts
The native WS transport reset its reconnect counters as soon as the WS
upgrade succeeded. Qobuz cloud accepts the WS connection well before the
JOIN_SESSION exchange and rejects unhealthy sessions with MSG_TYPE_ERROR
right after, so the counter reset every cycle and the loop hammered the
endpoint at base backoff (~2s) forever (issue #358).

Move the counter reset to the moment the cloud emits a
MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE event (the actual proof of a healthy
session-level join), and add a configurable maximum-attempts cap that
makes the transport stop and emit MaxReconnectAttemptsExceeded instead
of looping indefinitely.

- Add SessionEstablished and MaxReconnectAttemptsExceeded transport events.
- Add reconnect_max_attempts to WsTransportConfig (default Some(10)).
- handle_incoming_binary now returns whether SESSION_STATE was decoded.
- Reconnect helper returns a ReconnectOutcome enum (Continue/Shutdown/
  Exhausted) instead of a bare bool.
- qconnect_service.rs logs the new events and emits a diagnostic when
  the cap is exceeded so the higher layer can react (#358-fix-3 will
  surface it in the UI).
- Unit tests cover the cap behavior and the unbounded fallback.

Refs #358

cc
2026-04-25 20:01:42 -06:00
vicrodh e3caff7f65 chore(audit): add config::favorites_cache to legacy_module_roots
Completes the legacy root list by adding config::favorites_cache,
which is registered in lib.rs (e.g., is_track_favorite) but was
missing from the audit's known-roots list. No frontend currently
invokes it, so this is preventive — guards against future silent
misclassification as 'unknown'.

Found in code review of commit 3ea96fb5.

cc
2026-04-25 19:45:01 -06:00
vicrodh 3ea96fb5ee chore(audit): classify legacy commands by leaf identifier and add roots
The frontend invoke audit was capturing the wrong segment of legacy
handler registrations. The pattern 'commands::([A-Za-z0-9_]+)' grabbed
the submodule name (e.g. 'search', 'playback') instead of the function
leaf, so commands like 'commands::search::get_artist_albums' fell into
the unknown bucket even though they were registered.

Switch to a per-root regex of the form
'<root_path>(::<segment>)*::(<leaf>),' which captures the leaf
identifier regardless of how many submodule levels sit between the root
and the function. Also add four legacy module roots that were missing:
- updates (has_flatpak_welcome_been_shown, has_snap_welcome_been_shown)
- config::legal_settings (get_qobuz_tos_accepted)
- desktop_theme (detect_desktop_theme)
- flatpak (get_flatpak_help_text)

After this change the unknown bucket is empty and the 16 callsites that
were previously misclassified as 'unknown' now correctly show up as
'legacy', which is the accurate status pending future V2 migration.

cc
2026-04-25 18:23:53 -06:00
vicrodh 4f0e4e6c81 fix(commandRouter): rewire audio invokes to actual v2 command names
cmdUpdateAudioSettings was invoking four v2 commands that do not exist:
- v2_set_audio_backend       -> v2_set_audio_backend_type
- v2_set_audio_device        -> v2_set_audio_output_device
- v2_set_exclusive_mode      -> v2_set_audio_exclusive_mode
- v2_set_dac_passthrough     -> v2_set_audio_dac_passthrough

Also corrects the parameter for set_audio_output_device, which expects
'device' (not 'deviceName'). The other three already use the correct
parameter names. The function currently has no callers, but keeping the
shape correct prevents a future caller from hitting silent failures.

cc
2026-04-25 18:23:01 -06:00
vicrodh 404296f49f fix(diagnostics): rename v2_qconnect_get_status to v2_qconnect_status
The frontend was invoking 'v2_qconnect_get_status', but the backend
command is registered as 'v2_qconnect_status' under qconnect_service.
This caused two callsites in the diagnostics path to fail at runtime:
- src/lib/services/diagnosticsSnapshot.ts
- src/lib/components/DiagnosticsPanel.svelte

cc
2026-04-25 18:22:22 -06:00
vicrodh 58443b020e fix(scripts): audit-frontend-invokes recognizes commands from any namespace
The previous regex only matched commands registered under commands_v2::,
missing 50+ legitimate v2_qconnect_* commands registered under
qconnect_service::. This produced 64 false-positive "missing_v2" callsites
that obscured real audit signal.

Generalize the registration regex to match any module prefix ending in
::v2_* or ::runtime_*, so future modules that follow the v2_ naming
convention are picked up automatically without script changes. The inner
alternation still excludes legacy commands, which never carry the v2_ or
runtime_ prefix on the function name.

After the fix, missing_v2_callsites drops from 64 to 6 (the remaining 6
are real frontend typos, not audit-script bugs); legacy_callsites stays
at 0; unknown_callsites stays at 16.

cc
2026-04-25 18:07:06 -06:00
vicrodh 9cfaf755f5 Merge feature/internal/track-shuffle-mix into pre-release 2026-04-25 17:46:36 -06:00
vicrodh 13bd95a663 fix(playlist): rebuild custom order from visible state on every move
Custom-sort reordering inside a playlist (arrow buttons + drag and
drop) silently no-op'd or shifted by the wrong amount. Investigation
on a 15-track playlist showed customOrderMap with duplicate positions
(two tracks at position 6) and the moved track at map-position 10
while displayed at index 11 — the in-place shift algorithm assumed
display-index and map-position lived in the same number space, which
is only true when positions are unique 0..N-1.

Once any duplicate sneaked in, every subsequent click hit the no-op
path: target newPosition equalled current map-position, the per-key
shift loop's `pos > fromIndex && pos <= toIndex` window collapsed to
empty, and the backend's move_playlist_track saw current==new and
returned without writing. The user had to toggle sort mode to force
loadOrInitCustomOrder to re-fetch backend state — which masked the
fact that the local algorithm itself was wedged.

This rewrites moveTrack to ignore the existing map values and instead
splice the visible displayTracks order, then renumber 0..N-1. The new
order is shipped to v2_playlist_set_custom_order, whose backend impl
is a DELETE + INSERT for the playlist, so any pre-existing corruption
self-heals on the first move.

Concurrent clicks are serialized through a Promise chain so backend
writes land in click order even though tokio's mutex isn't strictly
FIFO. The optimistic local update happens synchronously before the
chain dispatch, so subsequent clicks see the post-move displayTracks
state without waiting for the round-trip.

Note: the latent bug in database.rs::move_playlist_track that lets
duplicate positions exist in the first place (the INSERT branch on
line 3354 doesn't shift existing rows) is not addressed here — the
new flow simply doesn't use that code path. Backend cleanup is a
separate fix.

cc
2026-04-25 13:06:21 -06:00
vicrodh 603c78cadd fix(ui): rebuild TrackMixModal on shared Modal + Dropdown + .btn classes
Replaces the first-pass implementation that rolled its own backdrop and
pill-style size buttons. The pills had two problems: pills are forbidden
by the project design rules (qbz-nix-docs/design/FIGMA_PROMPT.md), and
the custom modal centered itself with `transform: translate(-50%, -50%)`
which created a containing block for `position: fixed` descendants —
that broke `Dropdown.svelte`'s portaled menu, which landed in the
bottom-right corner of the screen instead of below the trigger.

Now wraps `Modal.svelte` (portaled to body, flexbox-centered, ESC
built in), uses `Dropdown.svelte` for the size picker, and follows
the project convention of `<div class="modal-actions">` + the global
`.btn .btn-primary` / `.btn .btn-secondary` classes for the footer.

Copy is reworked to read like a sentence: title "Random queue", body
"Create a random queue with songs from this collection. How many songs
do you want?", confirm button "Add to queue". Synced across en/es/de/fr/pt.
Drops the now-unused `mixModal.subtitle` key.

Also removes the duplicated mixModalOpen entry from the view's
keydown ESC chain — `Modal.svelte` already handles ESC.

cc
2026-04-25 13:05:42 -06:00
vicrodh 656b40cd13 chore(deps): refresh Cargo.lock for strsim direct dep
Side effect of promoting strsim from transitive to direct in commit
f048ee76 (feat(mixtape): add shuffle module with normalize +
token_set_ratio).

cc
2026-04-25 06:30:21 -06:00
vicrodh 83c276dc43 feat(ui): wire DJ Mix button into collection/mixtape detail view
Adds a third circular header action between Shuffle and More that
opens TrackMixModal, then delegates the resolved sampleSize to
v2_collection_shuffle_tracks. Replaces the queue, starts playback,
and surfaces a toast when actualCount < requestedCount (dedup or
per-album cap shrunk the pool).

ESC chain extended to dismiss the mix modal before closing other UI.
collection.play_mode is left untouched.

cc
2026-04-25 06:26:22 -06:00
vicrodh 48f4d130e4 feat(ui): add TrackMixModal with size picker + i18n keys
- TrackMixModal.svelte renders a centered radiogroup of queue sizes
  (50, 100, 150, …, "All (N)") backed by buildSizeOptions. Fetches the
  unique track count via v2_collection_unique_track_count when first
  opened for a collection and caches it for subsequent opens of the
  same collection.
- buildSizeOptions is a pure helper extracted to a separate file so it
  can be unit-tested cleanly. 8 vitest cases cover the empty/null,
  small, exact-50, multiple-of-50, and irregular paths.
- Adds 8 new i18n keys (common.shuffleTracksMix, mixModal.title /
  computing / subtitle / allOption / confirm / loadFailed,
  toast.mixTrimmed) across all 5 locale files.
- z-index 3000/3001 per ADR-003.

cc
2026-04-25 06:24:09 -06:00
vicrodh 404d6f431b feat(v2): add v2_collection_shuffle_tracks command
End-to-end DJ-mix command. Loads the collection, resolves its natural
in-order track list, dedupes near-duplicate versions, samples up to
sample_size tracks under the per-album cap, replaces the queue, and
starts playback from index 0. Stamps queue_source_collection and bumps
the play counter (mirroring v2_enqueue_collection's replace mode), but
does not modify the collection's persistent play_mode.

Returns ShuffleTracksResult { requestedCount, actualCount } so the
frontend can show the trimmed-mix toast when the cap or dedup shrinks
the pool.

ThreadRng is not Send, so dedup + sample run inside a sync scope that
ends before the bridge awaits.

cc
2026-04-25 06:18:24 -06:00
vicrodh da33e43257 feat(v2): add v2_collection_unique_track_count command
Resolves the collection's natural in-order track list and returns the
count of distinct songs after similarity-based deduplication. Used by
the upcoming track-mix modal to size the slider of pickable queue
sizes. Auth gated; deterministic output.

cc
2026-04-25 06:12:02 -06:00
vicrodh 26b39495d8 feat(mixtape): add hybrid_sample with per-album cap
Round-robin sampling that picks up to `requested` tracks from a
deduped track list. Each album bucket is shuffled in place and
truncated to cap = max(2, ceil(requested * 0.30)); rounds permute
the album order so cross-album distribution is randomized too. May
return fewer than `requested` when the per-album cap and bucket
sizes do not add up — callers report this gap to the user.

7 new tests (37 total) including a 200-seed statistical test
confirming the cap is never exceeded.

cc
2026-04-25 06:05:18 -06:00
vicrodh 6649ee0759 feat(mixtape): add dedup_by_similarity and unique_track_count
- unique_track_count returns the count of distinct songs after grouping
  near-duplicate versions; deterministic, no RNG.
- dedup_by_similarity collapses each duplicate group to one survivor
  picked at random via the supplied RNG. Output preserves input order
  of survivors.

Both share a single bucketed Union-Find pass: tracks are first grouped
by exact normalized artist, then unioned within each artist bucket
when their normalized titles score >= 0.80 on token_set_ratio. Cross-
artist comparisons are skipped to avoid false positives like "Yesterday"
by different artists.

12 new tests (30 total in this module) including a 200-seed statistical
test confirming every duplicate version can be selected as the winner.

cc
2026-04-25 05:34:11 -06:00
vicrodh f048ee7645 feat(mixtape): add shuffle module with normalize + token_set_ratio
Pure-function helpers used by the upcoming track-mix sampler:
- normalize_title strips parens/brackets, dash suffixes, feat patterns,
  punctuation, and diacritics.
- normalize_artist keeps parens (band suffixes carry meaning) and just
  lowercases + strips diacritics.
- token_set_ratio implements the RapidFuzz-style token-set similarity in
  [0.0, 1.0] using strsim's normalized Levenshtein.

Promotes strsim from transitive to direct dep. 18 unit tests cover the
core normalization and similarity behaviors.

cc
2026-04-25 05:22:02 -06:00
vicrodh 75efe7f8e9 feat(ui): add DjIcon component
Inlined SVG component for the upcoming track-shuffle mix button.
Uses fill="currentColor" so it inherits theme color. The source
SVG (static/dj-1.svg) is kept as a reference asset.

cc
2026-04-25 05:08:05 -06:00
vicrodh 828f63dc22 Merge branch 'feat/internal/low-memory-auto-detect' into pre-release
Memory mitigations for issue #331 (Pi 3B / 1 GB hosts):

- qbz-core::system_capabilities reads /proc/meminfo at startup and
  derives a MemoryProfile (Normal vs LowMemory). Cached in OnceLock.
- V2 prefetch (commands_v2/playback.rs) caps prefetch_count and
  max_concurrent_prefetch from the profile, downgrades HiRes to
  Lossless when allow_hires_prefetch is false.
- StreamingConfig::from_speed_mbps clamps the dynamic initial
  buffer to a process-wide cap configured from the profile (256 KB
  on LowMemory vs 2 MB historical max).
- AudioCache L1 cap also scales (50 MB on LowMemory vs 400 MB).
- Tokio watchdog spawned on LowMemory hosts polls MemAvailable
  every 10 s; flushes audio caches when below 5 % to break the
  swap-thrash loop codehd7 reported.

Includes the rodio 0.22 NonZero test fix (also in the bugfix
merge above) so the branch validates standalone.

cc
2026-04-24 19:51:30 -06:00
vicrodh c471c03eba Merge branch 'bugfix/internal/issue-331-fallback-gate' into pre-release
Fixes the iec958: -> hw:CARD=...,DEV=... fallback gate that was
silently disabled for non-USB sound cards (HifiBerry, intel-hda,
etc.) because the previous gate required /proc/asound/cardN/stream0,
which only USB-audio drivers emit. The HifiBerry Digi2 Pro on
Raspberry Pi OS reported in #331 hits exactly this case.

Also bumps the visualizer test fixtures to rodio 0.22's
NonZero<u16>/NonZero<u32> SamplesBuffer::new signature so
cargo test -p qbz-audio compiles.

cc
2026-04-24 19:51:17 -06:00
vicrodh d98cc569b0 test(visualizer): wrap SamplesBuffer args in NonZero for rodio 0.22
rodio 0.22 tightened SamplesBuffer::new to require NonZero<u16> for
channels and NonZero<u32> for sample_rate, but the visualizer test
fixtures still passed bare integer literals — so the qbz-audio test
binary stopped building. Wrap both call sites in NonZero::new(...)
.unwrap() so cargo test -p qbz-audio compiles again.

Pure test-only change; runtime visualizer code was already migrated.

cc
2026-04-24 19:46:05 -06:00
vicrodh 89c0d65285 feat(memory): scale L1 audio cache cap to host memory profile (#331)
Add audio_cache_l1_max_bytes to MemoryProfile and wire it into
AppState's AudioCache constructor. Normal hosts keep the historical
400 MB cap; LowMemory hosts drop to 50 MB.

Without this, a Pi 3B (1 GB total) starts with the L1 cache cap set
at ~40 % of physical RAM. Even with the Feature #3 prefetch caps
already in place, the runtime watchdog from the previous commit
ends up firing more often than necessary because nothing prevents
a single L1 fill cycle from claiming 400 MB. Capping at 50 MB caps
how much the cache can grow before either: (a) eviction picks up
naturally, or (b) the watchdog steps in if the rest of the system
is also under pressure.

Pure refactor in lib.rs — replaces a hard-coded 400 * 1024 * 1024
with a profile field read. The default-constructed AudioCache
fallback (when disk-spillover init fails) keeps its own crate
default; no need to thread the profile there since the failure
path is rare.

Two tests added: a presence-bound assertion in the existing Pi 3B
case, and an explicit comparison that LowMemory's cap is strictly
smaller than Normal's.

cc
2026-04-24 19:45:38 -06:00
vicrodh c6fc01961f feat(memory): spawn audio-cache eviction watchdog on low-memory hosts (#331)
Tauri setup() now spawns a tokio task that polls
qbz_core::system_capabilities::read_memory_pressure() every 10 s
when the resolved MemoryProfile classifies the host as LowMemory.
On Normal-class machines the task is never spawned, so polling cost
is zero where it isn't needed.

Behavior:
- pressure.is_critical (< 5 % MemAvailable) triggers
  audio_cache.clear() once per critical episode and logs at warn.
  Both L1 (in-memory tracks) and L2 (disk spillover) are flushed —
  the disk hit on subsequent playback is far cheaper than letting
  the OS swap-thrash through the audio thread.
- pressure.is_low (< 15 %) is logged once at info as an early
  warning but does not evict.
- Recovery (back above 15 %) logs once and arms the next critical
  episode.

The was_critical edge guard prevents log spam when pressure
oscillates near the threshold during heavy prefetch.

For codehd7's Pi 3B scenario (issue #331): when QBZ pushes
MemAvailable below ~47 MB (5 % of 938 MB), the watchdog flushes
the cache, frees memory back to the kernel, and the network stack
recovers from the swap thrash that was killing playback.

cc
2026-04-24 19:43:45 -06:00
vicrodh e8128b813a feat(core): add MemoryPressure snapshot for runtime watchdog
Extend qbz-core's system_capabilities with a parse_meminfo_available_kb
helper plus a small MemoryPressure value object that classifies the
host's current available memory against thresholds derived from
MemoryProfile. The watchdog (src-tauri side, next commit) polls this
to decide when to evict caches.

Thresholds intentionally simple for v1: is_low triggers under 15 %
available, is_critical under 5 %. Both are strict-inequality so the
exact-percent boundaries don't flap.

The pure pressure_from_figures(avail_kb, total_kb) factors out the
math from the /proc read so threshold behavior is tested without
touching the filesystem. Refactors the existing
parse_meminfo_total_kb to share a parse_meminfo_field_kb helper with
the new available-memory parser.

Adds 9 tests covering the parser, the threshold boundaries, the zero-
total divide-safety guard, and the codehd7 issue #331 scenario
(Pi 3B, ~25 MB available out of 938 MB → critical).

cc
2026-04-24 19:41:12 -06:00
vicrodh b38065a033 feat(streaming): cap StreamingConfig initial buffer per memory profile (#331)
StreamingConfig::from_speed_mbps inflates the initial buffer to 2 MB
on slow connections (< 1 MB/s) to absorb network jitter. On a healthy
desktop that's fine, but on a memory-constrained host the slow link
is itself a symptom: when QBZ on a Pi 3B starts allocating ~300 MB
of Hi-Res prefetch the kernel swaps, networking stalls, the speed
detector reports < 1 MB/s, and the buffer code reacts by allocating
ANOTHER 2 MB — exactly the wrong direction.

Add a process-wide AtomicUsize cap (defaults to usize::MAX, i.e.
no-op) plus a small public set_max_initial_buffer_bytes setter.
Refactor from_speed_mbps to clamp through the cap; extract the
speed-to-bytes ladder into a pure raw_initial_buffer_for_speed
helper plus a from_speed_mbps_with_cap variant that exposes the
clamping for unit tests without touching global state.

src-tauri/lib.rs's run() wires the cap into qbz-player from
qbz-core's MemoryProfile right after logging the profile. Effective
on Pi 3B (issue #331): all StreamingConfig::from_speed_mbps results
pin to 256 KB regardless of detected speed.

Re-export max_initial_buffer_bytes / set_max_initial_buffer_bytes
from qbz_player::player so external callers don't need to reach
into the private streaming_source module.

Adds 5 unit tests: ladder behavior, pass-through when cap is loose,
clamp at the LowMemory cap (the issue #331 case), no-op at the
Normal cap, and that max_buffer_bytes stays unchanged.

cc
2026-04-24 19:32:51 -06:00
vicrodh d616a0796a feat(playback): apply MemoryProfile to V2 prefetch (#331)
Wire qbz-core's detected MemoryProfile into the V2 prefetch loop so
memory-constrained hosts (Pi 3B and similar, < 2 GB RAM) cap their
in-memory footprint:

- V2_PREFETCH_COUNT becomes a function reading profile.prefetch_count
  (5 on Normal, 1 on LowMemory). Reading it once per loop call is
  cheap — OnceLock-cached behind the scenes.
- V2_MAX_CONCURRENT_PREFETCH likewise becomes a function; the
  semaphore lazy_static initializes from it once at process start.
- Effective prefetch quality gets a low-memory cap before the
  hardware-rate ladder runs. When profile.allow_hires_prefetch is
  false we drop UltraHiRes/HiRes to Lossless so each cached track
  stays under ~15 MB instead of ~60 MB. The hardware downgrade
  still runs after, so a low-memory + 48 kHz-only DAC still gets
  the right rate.
- lib.rs forces an early call to memory_profile() in run() so the
  resolved profile is logged at boot rather than at first prefetch.

For codehd7's reported case (Pi 3B, 1 GB, HifiBerry Digi2 Pro,
issue #331): the active profile becomes prefetch_count=1,
max_concurrent_prefetch=1, allow_hires_prefetch=false. Cached
prefetch footprint drops from ~5 × 60 MB = 300 MB to a single
~15 MB Lossless track — well within the headroom that 1 GB minus
WebView (~150 MB) leaves.

cc
2026-04-24 19:26:21 -06:00
vicrodh 0a4b4095f5 feat(core): add system_capabilities::MemoryProfile auto-detect
New module in qbz-core that probes /proc/meminfo at startup and
classifies the host into Normal (>= 2 GB RAM) or LowMemory (< 2 GB)
buckets, then derives runtime caps for memory-sensitive subsystems:
prefetch_count, max_initial_buffer_bytes, max_concurrent_prefetch,
allow_hires_prefetch.

Detection is split into a pure parse_meminfo_total_kb +
detect_profile_from_meminfo pair (testable with synthetic input)
and a thin detect_profile wrapper that reads the file. The
process-wide profile is cached in a OnceLock and logged once at
first access, so subsequent callers pay zero filesystem cost.

The Normal-fallback path covers macOS, Windows, and any Linux where
/proc/meminfo is missing or unparseable — we never accidentally
throttle a system whose memory we couldn't measure.

This commit only ships the module; wiring into the prefetch loop
and streaming buffer follows in the next commits.

Adds 10 unit tests covering the parser, the threshold boundary
(exactly 2 GB → Normal, just under → LowMemory), the Pi 3B and
Pi Zero 2W cases that motivated this work (#331), and the
unparseable-input fallback.

cc
2026-04-24 19:22:08 -06:00
vicrodh 18d8bc3572 fix(alsa): gate hw fallback on card presence, not stream0 rates (#331)
The hw:CARD=…,DEV=… fallback added in 1.2.8 (commit 927fabbd7) only
fired when the helper chain
  extract_card_name_from_device(device_id)
      .and_then(|card| get_hw_supported_rates(&card))
      .is_some()
returned Some — but get_hw_supported_rates parses
/proc/asound/cardN/stream0, which is only emitted by the USB-audio
driver. I2S, PCI and built-in cards (HifiBerry HATs, intel-hda, etc.)
don't have a stream0 file at all, so the gate evaluated to false for
the very devices that needed the fallback the most. The reporter's
HifiBerry Digi2 Pro on Raspberry Pi OS hit this exactly: 1.2.8's
ALSA Direct path logged "Looking for device: iec958:CARD=…", failed
the lookup, and dropped straight to the proc_found=false error
branch without ever attempting hw:CARD=sndrpihifiberry,DEV=0.

Extract a small is_card_present_in_proc helper that uses
find_card_number_by_name (which reads /proc/asound/cards — present
for every registered card regardless of bus) and use it for both
gate checks: the fallback trigger and the proc_found error message
selector. get_hw_supported_rates keeps its three legitimate uses
(actual rate validation in create_output_stream, and the two public
device_supports_sample_rate / get_device_supported_rates queries).

Adds 8 unit tests covering build_hw_fallback_id, the alias-prefix
branches of extract_card_name_from_device, the short-circuit cases
of is_card_present_in_proc, and the existing is_known_pcm_id filter.
The /proc-touching branches stay covered by hardware testing on the
reporter's Pi when v1.2.9-rc ships.

cc
2026-04-24 19:17:59 -06:00
vicrodh ec1bf38791 test(visualizer): wrap SamplesBuffer args in NonZero for rodio 0.22
rodio 0.22 tightened SamplesBuffer::new to require NonZero<u16> for
channels and NonZero<u32> for sample_rate, but the visualizer test
fixtures still passed bare integer literals — so the qbz-audio test
binary stopped building. Wrap both call sites in NonZero::new(...)
.unwrap() so cargo test -p qbz-audio compiles again.

Pure test-only change; runtime visualizer code was already migrated.

cc
2026-04-24 19:17:40 -06:00
vicrodh 4105d597a8 fix(i18n): add missing toast.playingAlbum key across all locales
Two $t('toast.playingAlbum', { values: { count } }) call sites in
+page.svelte (collection/mixtape detail play paths) were printing the
raw key because the translation was never added. The `|| fallback`
idiom after each call never fired either — $t() returns the key
itself on miss, which is truthy.

Add an ICU plural entry covering both callers: count=1 (detail-view
play button on an album) reads as "Playing album", count>1 (bulk
play-all) reads as "Playing {count} tracks". Mirrored across en, es,
de, fr, pt.

cc
2026-04-24 16:22:09 -06:00
vicrodh e05ed48d60 fix(views): close titlebar gap on pinned sticky headers
Both PlaylistDetailView's .tracks-sticky-header and ArtistDetailView's
.jump-nav opened a visible gap between the titlebar and the sticky
bar once pinned — scrolled content bled through as a blurred strip.
Two causes, both hit here:

- Subpixel/compositing rounding in WebKitGTK 2.50+ left a few px
  transparent at the pinned top edge. Promoting each bar to its own
  compositing layer via `will-change: transform; transform: translateZ(0)`
  mirrors the same workaround imageCacheService applies to <img>.

- Even with a compositing layer, the scroll container above the pin
  can briefly expose bg before the bar's fill kicks in. Add a solid
  `box-shadow: 0 -32px 0 0 var(--bg-primary)` so the bg extends
  upward and stays covered regardless of any remaining offset. For
  .jump-nav this composes with the existing downward drop shadow.

Also set `background: var(--bg-primary)` on .playlist-detail and
.artist-detail (the scroll containers) and bump .track-controls'
padding-top to -2px (CSS clamps to 0 but documents the intent), so
the toolbar sits flush against the bar border without the 8px
breathing-room the unpinned state had.

cc
2026-04-24 16:19:39 -06:00
vicrodh 580bdaf6bc perf(sidebar): warm playlist tile cache after initial load
cachedSrc gives each playlist row's tiles the backend disk cache on
the first scroll that reveals them — good on cold runs but still
visibly staggered if the user scrolls quickly after launch.

Fire a preloadImages batch from a $effect keyed on userPlaylists and
the collage toggle: as soon as the list lands (from SWR cache, a fresh
v2_get_user_playlists, or an offline refresh), every playlist's first
four Qobuz images get routed through the same _50.jpg downscale used
at render time and queued into the shared in-memory resolvedUrls map.
Fire-and-forget, dedupes against already-resolved URLs, skipped when
the user opted out. The net effect is that cachedSrc hits a warm map
by the time the <img> elements mount, so the sidebar scrolls without
the loading-placeholder flash that used to bridge the first backend
round-trip.

cc
2026-04-24 15:55:46 -06:00
vicrodh 11edb2b05d perf(sidebar): route collage tiles through the shared image cache
PlaylistCoverCollage rendered tiles with plain <img src={url}>, which
bypassed v2_get_cached_image (the backend disk cache used everywhere
else for Qobuz imagery) and the in-memory dedup map in
imageCacheService. Second-launch tile fetches hit the Qobuz CDN over
the wire when they should have been served from disk, and playlists
that happen to share a cover each paid for their own invoke round-trip.

Switch the two <img> sites to use:cachedSrc so tiles go through the
same pipeline the larger PlaylistCollage uses:
- on-disk cache persists across sessions
- resolved URLs are memoised per-session; duplicate covers fold to one
  backend call
- the action also applies the will-change/translateZ compositing hint
  that works around WebKitGTK 2.50+ texture eviction during scroll,
  removing the occasional placeholder flash on long sidebar scrolls

asset:// custom-cover URLs short-circuit inside cachedSrc so local
thumbs keep their existing path.

cc
2026-04-24 14:57:35 -06:00
vicrodh 7ac5c7e8a0 perf(audio): skip CPAL probe of virtual ALSA PCMs during enumeration
build_cpal_device_map iterated every PCM that cpal's ALSA host exposes
(dmix:, route:, surround51:, pulse, null, …) but only five name shapes
are ever looked up downstream: default, sysdefault:CARD=*, and
{front,hdmi,iec958}:CARD=*,DEV=*. Dropping the rest avoids holding
Device handles we never query and, combined with the new error
handler, removes another source of spurious libasound probes on
systems where PipeWire owns the hardware exclusively.

cc
2026-04-24 13:40:42 -06:00
vicrodh 134808c880 feat(audio): route libasound errors through log::debug
libasound's default error handler prints directly to stderr. During
device enumeration probes, PCMs that wrap dmix or surround routes emit
benign errors ("unable to open slave", "no matching channel map") that
cluttered the app log without indicating a real problem.

Install a process-wide snd_lib_error_set_handler shim from
AudioBackendFactory::create_backend (idempotent via std::sync::Once)
that forwards the file/line/function/err/fmt tuple to log::debug. The
messages stay available with RUST_LOG=debug and disappear during
normal runs.

Adds alsa-sys 0.3 as a direct Linux-only dependency on qbz-audio
(previously transitive via alsa/cpal). The handler is declared
non-variadic and transmuted into libasound's variadic function pointer;
ignoring the unused varargs is ABI-compatible on every platform we
support.

cc
2026-04-24 13:40:09 -06:00
vicrodh 7e74d9f9cd Merge branch 'chore/internal/typescript-6' into pre-release
Bumps typescript from 5.9.3 to 6.0.3. Validated on the feature branch
via svelte-check (0 errors), vite production build, and vitest
(no new regressions vs 5.9.3).

cc
2026-04-24 12:54:13 -06:00
vicrodh 1e77642d7a chore(deps-dev): bump typescript from 5.9.3 to 6.0.3
Validated on this branch:
- npm run check (svelte-check): 0 errors, 41 warnings (identical to 5.9.3)
- npm run build (vite production): succeeds in ~57s
- npm run test:run (vitest): same 2 pre-existing failures as pre-release,
  no new regressions

Replaces closed dependabot PR #347.

cc
2026-04-24 12:48:53 -06:00
vicrodh a569833daf fix(titlebar): accept full-height-rounded in controlsShape prop
The windowControlsStore ButtonShape union and the TitleBar CSS already
support full-height-rounded (Klassy default), but the TitleBar prop
type was narrower, causing a type error at the one call site in
+page.svelte. Widen the prop union to match.

cc
2026-04-24 12:28:44 -06:00
vicrodh 5395fd79bb fix(awards): rename loadingMore prop to isLoadingMore for grid
VirtualizedFavoritesAlbumGrid expects isLoadingMore; AwardAlbumsView
was passing {loadingMore} shorthand, triggering a svelte-check error.

cc
2026-04-24 12:28:44 -06:00
vicrodh 14b72b0582 chore(deps): bump rand from 0.8.5 to 0.8.6
Fixes GHSA-cq8v-f236-94qc (low: rand unsound with custom logger using
rand::rng()). Transitively bumped across both src-tauri and crates
workspaces via cargo update.

cc
2026-04-24 12:05:04 -06:00
vicrodh 19344c9037 chore(deps-dev): bump vitest from 4.1.4 to 4.1.5
cc
2026-04-24 11:43:59 -06:00
vicrodh 110ecf11cc chore(deps-dev): bump vite from 8.0.8 to 8.0.10
cc
2026-04-24 11:43:02 -06:00
vicrodh 674771b216 chore(deps): bump notify-rust from 4.14.0 to 4.16.0 in /src-tauri
cc
2026-04-24 11:40:51 -06:00
vicrodh d871d924aa chore(deps): bump rustls-webpki from 0.103.12 to 0.103.13 in /crates
cc
2026-04-24 11:38:52 -06:00
vicrodh 40189baade chore(deps): bump tauri-plugin-single-instance from 2.4.0 to 2.4.1 in /src-tauri
cc
2026-04-24 11:38:42 -06:00
vicrodh 592c70928f chore(deps): bump uuid from 1.23.0 to 1.23.1 in /src-tauri
cc
2026-04-24 11:38:32 -06:00
vicrodh 777a060fd5 chore(deps): bump ashpd from 0.13.8 to 0.13.10 in /src-tauri
cc
2026-04-24 11:38:24 -06:00
vicrodh a2e5f8567a chore(deps): bump open from 5.3.3 to 5.3.4 in /src-tauri
cc
2026-04-24 11:38:15 -06:00
vicrodh 60283f0a2a chore(deps): bump openssl from 0.10.75 to 0.10.78 in /src-tauri
cc
2026-04-24 11:37:51 -06:00
vicrodh c0ab9f54cb feat(multiselect): dock BulkActionBar at list end via IntersectionObserver
Replaces the always-fixed positioning with a sentinel-driven dock/float
mechanism. A zero-height sentinel rendered before the bar marks the
bar's natural flow position at the end of the selectable list. An
IntersectionObserver flips the bar between two states:

- Floating: position fixed, bottom: var(--player-bar-height), with
  left/width tracked from the sentinel's bounding rect so the bar
  matches the column width (no longer overlaps the album sidebar).
- Docked: in-flow static position right below the last item once the
  sentinel scrolls into view, so the bar settles below the list
  instead of covering content below.

Also removes the 8px gap between the floating bar and the player bar.

cc
2026-04-24 11:37:19 -06:00
vicrodh a6eefae6b3 feat(multiselect): float BulkActionBar above player bar
Switch .bulk-bar from sticky to fixed positioning so it sits above the
player bar (bottom: player-bar-height + 8px) instead of clipping against
the scroll container. Matching adjustments for placement-top and a
higher z-index (40) to clear other overlays.

cc
2026-04-24 11:37:19 -06:00
vicrodh 1ee9cf8f80 feat(multiselect): albums tab select-all, shift-range, Ctrl/Cmd+A
Finish out #342 by giving the Albums tab the same treatment as the
Tracks tab. The plumbing mirrors what VirtualizedTrackList got:

- VirtualizedAlbumList now owns the shift-click anchor. Its
  onAlbumToggleSelect widens to accept the MouseEvent/KeyboardEvent,
  and a new onAlbumToggleSelectRange callback fires with every
  album id between the previous click and the shift-click. The
  ordered id list is derived from the same groups input the grid
  renders from, so the range matches what the user sees.
- LocalLibraryView gets addAlbumsToSelection (merge helper) plus
  albumSelectAllState / toggleAlbumSelectAll, the tri-state
  select-all checkbox shown next to the SquareCheckBig toggle
  while albumSelectMode is active, and a Ctrl/Cmd+A listener
  scoped to that mode. Reuses the .select-all-checkbox styles
  added for the Tracks tab.

Range selection in the Albums tab grid is the last piece the issue
called out, so #342 is fully addressed on the app side now.

cc
2026-04-24 11:37:19 -06:00
vicrodh fdfa02fe08 feat(multiselect): tracks tab select-all, shift-range, Ctrl/Cmd+A
LocalLibraryView's Tracks tab runs through VirtualizedTrackList,
so the shift-click anchor lives in the list component. Here we
only have to wire the batch-add callback:

- addTracksToSelection(ids) merges a range into selectedTrackIds
  and is passed to VirtualizedTrackList as onToggleSelectRange.
  The per-track onToggleSelect path stays as it was.
- Add the select-all checkbox NorinB asked for in #342: it shows
  up in .track-controls only while trackSelectMode is active, uses
  the familiar none/partial/all tri-state, and toggles between
  "clear selection" and "select every track in the library". The
  checkbox operates on the full `tracks` array rather than what's
  visible in the virtualized viewport, which is the behaviour he
  wanted (one click = everything).
- Ctrl/Cmd+A while in select mode does the same as clicking the
  checkbox when it's empty. Listener is scoped to the select-mode
  lifecycle and skipped when focus is in an input (search bar).

Small CSS for the new inline checkbox so it sits in the controls
row without looking out of place.

cc
2026-04-24 11:37:19 -06:00
vicrodh dab73cb528 feat(multiselect): shift-click + Ctrl/Cmd+A across remaining views
Finish rolling out the #342 pattern to the last four select-mode
views. Three of them follow the same shape as AlbumDetailView, but
with view-specific wrinkles:

- ArtistDetailView: popular tracks use a hand-rolled row (not
  TrackRow); updated the row onclick / onkeydown and inline
  checkbox wrapper to forward the MouseEvent.
- PlaylistDetailView: selection is keyed by a composite
  "trackId:isLocal" string (playlists mix Qobuz and local tracks),
  so the range logic still operates on an index array but the ids
  passed into the Set are the composite keys produced by
  getTrackKey(). The existing drag-to-reorder path uses a separate
  selectedTrackKeys set and is left untouched.
- MixtapeCollectionDetailView: items are keyed by their numeric
  position within the collection, so the range calculation runs
  over the ordered positions array. Used in both the grid and list
  view-mode branches.

FavoritesView is the first view that renders its tracks inside
VirtualizedTrackList, so I pushed the shift-anchor down into the
list component. VirtualizedTrackList now keeps its own ordered
track-id list (computed from `groups` + `buildAlbumSections` so
the order matches what the virtual items emit) and a
lastSelectedIndex anchor. On shift-click it resolves the range
against that ordered list and emits the added ids via a new
`onToggleSelectRange` callback. Plain toggles still go through the
existing `onToggleSelect` so callers that don't opt in keep their
current behaviour unchanged. FavoritesView wires
onToggleSelectRange to a simple "add these ids to the set" merge,
which is exactly what we want for range selection.

Each view also gains the Ctrl/Cmd+A listener, gated on its own
select-mode flag and on isSelectAllShortcut (no firing while
focus is in a search input).

cc
2026-04-24 11:37:19 -06:00
vicrodh 7219b530a8 feat(multiselect): shift-click + Ctrl/Cmd+A in Qobuz list views
Apply the AlbumDetailView pattern to the remaining select-mode
views over Qobuz track lists (issue #342):

- FavQView, TopQView, WeeklySuggestView, DynamicSuggestView:
  same shape as AlbumDetailView — filteredTracks is the range
  basis, onToggleSelect now forwards (id, index, event).
- LabelView: same logic, but the row here is a hand-rolled div
  (not TrackRow). Updated the row onclick / onkeydown and the
  inline checkbox wrapper so the MouseEvent with shiftKey reaches
  toggleMultiSelect. Dropped the native onchange handler on the
  checkbox in favour of preventDefault-on-click; the reactive
  `checked={selected}` keeps the visual state in sync.

Each view also gains a Ctrl/Cmd+A listener that is only attached
while multi-select mode is active and is ignored when focus is in
an input (via isSelectAllShortcut).

cc
2026-04-24 11:37:19 -06:00
vicrodh a0d60841bc feat(multiselect): shift-click range + Ctrl/Cmd+A in AlbumDetailView
Implement the pattern for issue #342 in AlbumDetailView as the
reference for the other select-mode views:

- Track `lastSelectedIndex` as an anchor, reset when leaving
  multi-select mode.
- On toggle, if shiftKey is set and we have an anchor, expand the
  selection to include every track between the anchor and the
  clicked index (standard file-manager semantics: range is additive,
  the clicked track becomes the new anchor).
- A `keydown` listener bound while multi-select mode is active
  grabs Ctrl/Cmd+A and selects every track. Gated by
  isSelectAllShortcut so it's a no-op when focus is in an input.

The select-mode toggle button stays — shift-click operates inside
the already-active mode, per the product decision on the issue.

cc
2026-04-24 11:37:19 -06:00
vicrodh ab029eb351 feat(multiselect): add shift-range utility and propagate MouseEvent
Lay the groundwork for shift-click range selection in multi-select
views (issue #342):

- Add src/lib/utils/multiSelect.ts with applyShiftRange() and
  isSelectAllShortcut() so the logic is defined once and each view
  just wires it to its own selection set.
- TrackRow: the native checkbox was using onchange which fires a
  plain Event and drops shiftKey. Move toggle handling onto the
  wrapper div with preventDefault on the input so the click event
  (with shiftKey) reaches onToggleSelect. The checkbox stays
  visually driven by the reactive `selected` prop.
- AlbumCard: widen onToggleSelect signature from `() => void` to
  `(e: MouseEvent | KeyboardEvent) => void` and forward the event
  from both the card click and the Enter/Space keyboard path.

Existing callsites that pass `() => toggleFoo(id)` keep working
because a zero-arg callback is assignable to the wider signature.

cc
2026-04-24 11:37:19 -06:00
vicrodh 1a4aa26b8b docs(api-keys): reflect proxy-based architecture, remove .env instructions
.env.example is now a placeholder pointing to the hosted Cloudflare
Workers proxy. README updates the API Proxy section accordingly and
.gitignore adds CLAUDE.md and AGENTS.md.

cc
2026-04-24 10:23:57 -06:00
vicrodh 3d42214775 docs(build): credit #354 and document future AVX2 pattern
Expand the .cargo/config.toml comment to reference both reports of
the SIGILL-at-launch bug: issue #353 (Sandy Bridge on Linux) and
discussion #343 (2012 MacBookPro9,1 / Ivy Bridge on macOS). The
macOS Intel case was diagnosed independently by @afonsojramos, who
opened PR #354 with the same fix landed earlier in b54574a7.

Also codify the forward-looking note afonsojramos suggested in that
PR: if a future hot loop needs AVX2/BMI2, the correct pattern is
per-function #[target_feature] with runtime dispatch (what the `aes`
crate already does via cpufeatures), not a global target-cpu bump
that silently breaks older hardware. This way the rationale is in
the file the next time someone is tempted to raise the baseline.

No code change, comment-only.

cc
2026-04-23 22:46:21 -06:00
vicrodh b54574a786 fix(build): drop x86-64-v3 baseline so pre-Haswell CPUs don't SIGILL
Issue #353 reports v1.2.8 dying with "Illegal instruction (core
dumped)" at launch on Sandy Bridge (Intel HD 2000, 2011). The crash
is not in CMAF decrypt — it's the process never reaches user code.
Root cause: .cargo/config.toml set target-cpu=x86-64-v3 which is
the Haswell (2013) microarch level. LLVM then freely emits AVX2,
BMI2, FMA, LZCNT and MOVBE across the whole binary, including the
Rust/Tauri startup path. Any pre-Haswell x86_64 CPU (Sandy Bridge,
Ivy Bridge, AMD Bulldozer/Piledriver) SIGILLs on the first of those
instructions, long before CMAF decrypt or the UI mounts.

The x86-64-v3 baseline was added together with +aes,+ssse3 thinking
it was required to activate AES-NI. It is not. AES-NI is an
orthogonal ISA extension; target-feature=+aes alone is what gets
the aes crate to compile in the AES-NI code path, and its
cpufeatures::new! runtime CPUID check then dispatches to hardware
or software per-process. So the 40x CMAF decrypt speedup (22s ->
<500ms on a 208 MB HiRes bundle) came from +aes, not from v3.

Drop target-cpu=x86-64-v3. Keep target-feature=+aes,+ssse3:
  - Modern CPUs with AES-NI still get the hardware decrypt path.
  - CPUs without AES-NI get the software fallback via cpufeatures
    runtime dispatch — slower but correct, no crash.
  - SSSE3 has been in every x86_64 CPU since 2006 (Core 2).
  - Pre-Haswell CPUs stop SIGILLing at launch.

What we lose: some AVX2/BMI2 auto-vectorization in general codegen
(stdlib, symphonia decode paths). CMAF decrypt — the actual
bottleneck we were optimizing — is unaffected.

cc
2026-04-23 08:43:42 -06:00
vicrodh 20787766f7 fix(themes/stratego): bright crimson accent for foreground legibility
The initial Stratego palette used a deep crimson (#a0161d) as accent:
perfect AAA contrast with bone-white button text, but invisible when
used as foreground color on dark backgrounds — and the app has ~300
components that do exactly that (card labels, tab underlines, volume
fill, subscription chip, link text, progress markers, etc.).

Swap for a bright crimson (#ed2f3d) + near-black button text. Matches
the mask's face-stripes and the mandala glow instead of the shadowed
mandala. Accent now:
- reads as foreground text on every bg tier (AA 4.25-4.75:1)
- still AAA on large button text (4.75:1) with near-black label
- keeps hover brighter (#f74a58) and active deeper (#c41e2a)

Addresses user feedback on the first Stratego pass where card labels
("ARTIST"/"COLLECTION"), the account-tier chip under the username,
and the volume-slider fill were unreadable.

cc
2026-04-21 21:29:54 -06:00
vicrodh 0292c8650d feat(themes): add Stratego (imperial crimson + bone white, AAA contrast)
Dark theme inspired by a samurai mask + war mandala. Palette pulled
from the source image: near-black canvas, bone-white ivory for text
(matches the mask teeth and kanji), imperial crimson as accent, antique
gold for warnings (dragon crest horns), steel-grey for borders.

Contrast verified AAA on the critical pairs:
- accent-primary #a0161d on btn-primary-text #fcfaf2 -> 7.55:1
- text-primary #ece6d6 on bg-primary #0a0a0b -> 15.7:1
- text-secondary #b5afa0 on bg-primary -> 8.7:1

Registered in SettingsView picker next to Iscariot, translations added
to all five locales (proper name, not localized).

cc
2026-04-21 21:08:30 -06:00
vicrodh dfd8f84afd chore(flake): bump to v1.2.8
Update qbzVersion, srcHash (via nix-prefetch-url of the tag tarball) and
npmHash (captured from nix build with fakeHash).

cc
2026-04-21 20:38:49 -06:00
vicrodh 0b481e5332 chore(release): bump version to 1.2.8 "My QBZ"
Mixtapes & Collections release. Bumps all version references
(Cargo.toml, tauri.conf.json, package.json, snapcraft.yaml, both
Cargo.lock, package-lock.json), adds the 1.2.8 entry to the Flatpak
metainfo, and sets the About modal codename to "My QBZ".

cc
2026-04-21 19:25:24 -06:00
Victor RH 230f2dc390 Merge pull request #341 from vicrodh/dependabot/cargo/src-tauri/rustls-webpki-0.103.13
chore(deps): bump rustls-webpki from 0.103.10 to 0.103.13 in /src-tauri
2026-04-21 19:17:34 -06:00
vicrodh 51ff1c4f37 fix(playlist-detail): plex in queue with source marker so auto-advance works
Revises the previous commit's plex-in-queue filter. Turns out the
backend queue is a passive data structure — it doesn't play tracks
itself. Playback on queue advance goes through the frontend's
onTrackEnded callback:

  nextTrack() → BackendQueueTrack → playQueueTrack → playTrack({..., source})

…and playTrack already dispatches source='plex' to v2_plex_play_track
on line 299. BackendQueueTrack has an optional `source?: string` field
for exactly this reason; the previous build just wasn't populating it
for playlist tracks.

Changes in buildQueueTracks:
  * Plex tracks are no longer filtered out of the queue.
  * Each queue entry now carries an explicit source: 'plex' | 'local'
    | 'qobuz' marker derived from the DisplayTrack's flags. That's
    what resolvePlaybackSource reads to pick the right playback
    command when auto-advance fires.
  * localTrackIds (used by offline-mode availability) still only
    contains filesystem-local ids — plex ids shouldn't land in there
    because that Set is validated against local_tracks.

Direct-click plex playback still works via handleTrackClick's isPlex
branch (commit f9b7290e). The remaining limitation — Play Next / Play
Later for plex tracks — is still a no-op because those enqueue-single
paths haven't been taught to pass source='plex' through to the
backend's add-to-queue; separate follow-up.

cc
2026-04-21 15:35:31 -06:00
vicrodh f9b7290e90 fix(playlist-detail): route plex tracks through v2_plex_play_track
Plex tracks added to a Qobuz playlist weren't playable:

  * Clicking the row hit onTrackPlay → playTrack with no source arg →
    backend treated the id as a Qobuz track id and rejected it with
    "invalid track id for remote handoff: -1000017964" (the negative
    plexDisplayId UI-only offset was the id we sent).
  * Auto-advancing from the previous (local) track mapped the plex
    DisplayTrack's negative id through Math.abs into the local-track
    queue, which then looked 1_000_017_964 up in local_tracks and
    errored with "Track not found".

Changes:

1. plexTrackToDisplay now uses Number(ratingKey) as id when the
   ratingKey parses as a positive integer, matching the shape Plex
   backend / LocalLibraryView already use (PlexCachedTrack.id =
   playback_track_id(rating_key) = the numeric rating_key for the
   common case). The negative-offset hash stays as the fallback for
   non-numeric rating keys — those still need UI-unique ids but are
   rare enough that no-play is an acceptable degradation.
2. isLocal stays true so the Qobuz context-builder and blacklist
   filter keep their existing semantics; the new isPlex flag drives
   routing at the dispatch sites.
3. PlaylistDetailView.Props gains onPlexTrackPlay (track) → void.
   handleTrackClick checks track.isPlex first and dispatches through
   it; handleTrackPlayNext / handleTrackPlayLater no-op on plex for
   now (backend queue doesn't understand plex yet).
4. removeTrackFromPlaylist checks track.isPlex first and calls
   v2_playlist_remove_plex_track with ratingKey: String(track.id) so
   the "Remove from playlist" menu item works on plex rows too.
5. +page.svelte gets handleDisplayPlexTrackPlay — mirrors
   handleLocalTrackPlay's plex branch (playTrack with source='plex',
   isLocal:false). Wired via onPlexTrackPlay on the PlaylistDetailView
   render.
6. buildQueueTracks filters plex out entirely. The local-track queue
   path in the backend looks ids up in local_tracks; plex ratingKeys
   aren't there and the mismatch was the source of the auto-advance
   "Track not found" toast. Plex tracks keep their direct-click
   playback; gapless auto-advance across plex will require backend
   queue awareness (deferred).

Known limitation carried forward: Play Next / Play Later / auto-
advance across plex tracks are no-ops until the backend queue learns
to cross-reference plex_cache_tracks. Direct click on a plex row
plays correctly.

cc
2026-04-21 15:22:41 -06:00
vicrodh f9a7891802 feat(titlebar): default Discover, Library, Local Library, My QBZ, Search to titlebar
New installs open with the core nav items already in the custom
titlebar so the app feels "populated" right after login. Purchases is
excluded from the default set because it's an opt-in Qobuz feature
that not every account uses — keeping it off avoids a dead button for
most users.

Existing users are untouched. Both stores read localStorage in their
init* function and overlay the persisted values on top of these
defaults, so anyone who deliberately moved nav items back to the
sidebar (or never cared and just got the old all-false defaults)
keeps whatever they have.

Defaults now:
  titlebarNavStore: discover / favorites ("Library") / library
    ("Local Library") / myQbz → true. purchases → false.
  searchBarLocationStore: titlebar.

No effect on users who toggled off the custom titlebar itself — the
config still exists but isTitlebarNavEnabled() is a no-op for them.

cc
2026-04-21 15:01:16 -06:00
vicrodh 1d173a2cb1 feat(downloads): default show_in_library to true for new installs
Offline-cached Qobuz tracks should appear in the Local Library on a
fresh install — the whole point of caching is to have them available
alongside local files. Users who set the preference explicitly (on or
off) are untouched: the INSERT OR IGNORE in DownloadSettingsStore
only writes the default row when no row exists yet.

Three coordinated changes inside download_settings.rs:

  * DownloadSettings::default().show_in_library: false → true
  * Schema DEFAULT on the column: 0 → 1 (covers rows added post-
    migration from code paths that skip the explicit INSERT)
  * The INSERT OR IGNORE seed row now interpolates
    default_settings.show_in_library instead of hardcoding 0, so the
    code default and the SQL default stay in sync.

cc
2026-04-21 15:01:02 -06:00
vicrodh af1f23718c feat(immersive): default panel FPS to 30 across the board
The immersive panels defaulted to 15fps (linebed was the sole
override at 60). 15 is visibly choppy for audio-reactive motion; 60
is overkill for everything except the linebed terrain. 30fps is the
middle ground — perceptually smooth for the slow-evolving visuals
while keeping CPU/GPU headroom for machines that didn't ask for 60.

  * DEFAULT_FPS 15 → 30
  * linebed's 60 override removed (30 also reads fine for it)

getPanelFps still prefers the stored qbz-immersive-fps-<panelId> key
in userStorage when set, so users who manually tuned a panel higher
or lower keep their value. The new default only kicks in for panels
with no stored preference — i.e. new installs and panels the user
never touched.

cc
2026-04-21 15:00:48 -06:00
vicrodh a06b5fd8c3 style(mixtape-detail): homologate hero to PlaylistDetailView
The Collection / Mixtape detail hero was the outlier across app
detail views — 240px cover and 40px title against the 186px cover
and 24px title every other detail view (playlist, album) uses. That
made those two feel visually louder and taller than everything else.

Aligns with .playlist-header sizing:
  * cover 240 → 186
  * title 40px → 24px / line-height 1.2
  * kind tag 10px accent → 12px text-muted (matches .playlist-label)
  * gap 24 → 32
  * description line-height 1.4 to match

header-info stays column+flex-end so the metadata hugs the bottom
edge of the cover (same alignment as playlist / album detail views).

cc
2026-04-21 14:45:45 -06:00
vicrodh 18549eeb2d feat(mixtape-detail): grid card artwork spinner while image bytes fetch
Pure-CSS spinner on .grid-artwork-wrap::after, visible through the
transparent <img> element while it's mid-fetch. Once the image's
bytes paint, the opaque pixels cover the pseudo-element — no JS load
handler, no per-card reactive state, no extra re-render on load.
Placeholder cards (no artwork URL at all) hide the spinner behind
their own solid background via the lifted .grid-artwork-placeholder
rule.

Addresses the user's "add a spinner while caching" ask for the grid
view; the dark-square-during-fetch look was the bit that still felt
heavy even after preload + sync cache hits landed.

cc
2026-04-21 14:31:01 -06:00
vicrodh 1ded75b35e perf(image-cache): normalize Plex token out of cache keys for hits across sessions
Plex thumbnail URLs carry X-Plex-Token (and sometimes
X-Plex-Client-Identifier) as a query parameter. Those rotate each
time a token refresh lands, so the *exact same* /library/metadata/…/
thumb resource hashes to a different cache key between sessions, and
the backend dutifully re-downloads and re-caches the same bytes under
the new hash. On a collection that's mostly Plex (the user's 67-item
case), that means every open re-pulls 67 images over the network —
which is indistinguishable from "caching isn't working".

Normalize the cache key on both sides, keeping the original URL for
the actual authenticated fetch:

  * src-tauri: cache_key_for() strips X-Plex-Token and
    X-Plex-Client-Identifier before hashing. The `url` passed to
    reqwest is still the original with token, so the GET authenticates
    correctly; only the on-disk cache key is stable across sessions.
  * frontend: same normalization in imageCacheService.cacheKeyFor()
    covers the in-memory resolvedUrls map, pendingRequests dedup,
    getResolvedIfCached sync hit, and preloadImages "skip if already
    cached" short-circuit.

Non-Plex URLs are untouched — no '?' means no normalization work.
Qobuz's static.qobuz.com CDN URLs don't carry session params, so they
already cache correctly and this is a no-op for them.

cc
2026-04-21 14:24:51 -06:00
vicrodh c5db3895a0 fix(mixtape-detail): drop content-visibility from grid-card to stop scroll-back black flash
Grid-mode is already virtualized (windowedItems slice + top/bottom
spacers), so layering content-visibility:auto on top of that meant
every card off the viewport got ejected and re-painted when it came
back — the "areas ya scrolleadas se van a negro" the user flagged in
the recorded session.

Virtualization alone limits rendered cards to viewport + buffer, which
is what we actually want. content-visibility stays on .item-block
(list / expanded modes) where virtualization is disabled — that's the
mode that genuinely needs the engine-level skipping.

cc
2026-04-21 14:24:31 -06:00
vicrodh 63a1ea34bd perf(mixtape-detail): preload artwork + show sync cache hits to stop placeholder flash
The scroll lag in the grid view wasn't rendering cost — video analysis
(30fps extraction of a real session) showed consecutive frames stuck
identical (frame drops during scroll) plus dark placeholder cards
appearing at the bottom edge. Both symptoms trace back to the
cachedSrc action: it set opacity:0 + no src, then awaited a backend
proxy invoke per card before showing anything. On a 67-item collection
scrolling 30+ new cards into view, each new card paid a round-trip to
v2_get_cached_image before rendering — that's the placeholder flash,
and the JS/IPC load is what was eating the frame budget.

Three coordinated changes:

1. MixtapeCollectionDetailView.loadCollection() primes the image map
   up front via preloadImages(). By the time the user scrolls, every
   item's artwork URL has a resolved asset:// entry sitting in the
   resolvedUrls map. Requests the _150 variant so the preload matches
   what the grid cards actually display.

2. imageCacheService gains getResolvedIfCached() — a synchronous peek
   into the in-memory map. Returns the resolved URL if hot, undefined
   otherwise. file:// and asset:// short-circuit.

3. cachedSrc restructures around that sync path:
   - If the cache is warm, <img src> is set synchronously on mount +
     opacity stays at 1. Zero placeholder frames.
   - If not warm, <img src> falls back to the raw URL immediately so
     the WebView starts fetching in parallel with the backend proxy
     call. If the proxy returns a disk-cached asset:// we swap it in
     silently. No more opacity-dance between "loading" and "loaded".

Net effect: the first frame of each newly-visible card is already
painted with real artwork when the map was preloaded; the fallback
path only costs the raw HTTPS fetch (which the WebView already
handled in the old code too, just gated behind the proxy call).

cc
2026-04-21 14:16:54 -06:00
vicrodh 3edc6ca28f perf(mixtape-detail): native content-visibility on grid cards and item rows
Browser-native offscreen skipping for the detail view's heavy paths.

Grid:
  * .grid-card gains content-visibility: auto + contain-intrinsic-size
    of 170×240. The engine can now skip layout, paint, and compositing
    for cards scrolled out of the viewport — even though virtualization
    already clips the windowedItems slice, the buffer rows and items
    briefly off-screen during scroll were still fully rendered, which
    is the cost that was showing up as stutter on large collections.
  * Artwork request size bumps from _150 to _230 so the Qobuz thumbnail
    matches the actual card width on the common 170–240px range. No
    size change on HiDPI scaling, so crisper covers at the same-ish
    wire cost (cachedSrc dedupes identical URLs across cards).

Expanded / list:
  * Wrap each item-row + its optional expanded-tracks block in a new
    .item-block. Apply content-visibility: auto there too with an
    intrinsic hint of 100%×56 (collapsed row height). In expanded mode
    the recent fix to render every visibleItem made this necessary —
    50 items with all their TrackRows in the DOM was the pain point;
    now the engine skips rendering for anything scrolled away,
    bringing the cost back down to viewport-bounded even without
    virtualizing the TrackRows themselves.

cc
2026-04-21 13:46:27 -06:00
vicrodh e873fd4800 perf(mixtape-detail): compute grid row height from actual card width
Grid virtualization was using a hard-coded GRID_ROW_HEIGHT = 240.
That worked when cards were ~170px wide (the minmax floor), but
.item-grid uses auto-fill with 1fr tracks — on wider containers
each card expands past 170 and its 1:1-aspect artwork grows with it.
A card at 210px wide is ~272px tall, not 240. Over a 10-row grid
that's 320px of unaccounted content, same failure mode as the
expanded-row case: scrollbar stops short of the real bottom.

Replaces the constant with gridRowHeight derived from the actual
card width ((listContainerWidth - gaps) / gridColumns) plus a chrome
constant (title + subtitle + padding + inter-card gap). The
virtualWindow math now reads gridRowHeight each time instead of the
old fixed number, and it updates automatically when the user resizes
the window because listContainerWidth is already observed by the
existing bind:clientWidth.

Knock-on: the constant GRID_ROW_HEIGHT is gone; GRID_CARD_CHROME_H
takes its place as the part that doesn't depend on width.

cc
2026-04-21 13:38:17 -06:00
vicrodh b132c2bf64 fix(mixtape-detail): bypass virtualization in expanded mode so scroll reaches the end
The detail view virtualizes items with a fixed LIST_ROW_HEIGHT of 56.
In list / grid modes the real row height is 56 by design, so the math
works. In expanded mode the row grows every time a TrackRow mounts
below the item header — a typical album-expanded row is 300–500px,
not 56. The reserved top/bottom spacers fall far short of real
content height, so the scrollbar thinks the page ends where the
spacer math says it should and the user can't reach the last row:
"scroll se regresa, casi imposible llegar al final".

Fix: in expanded mode render every visibleItem (no slicing, no
spacers). The item-row shell is lightweight (56px + a tiny meta
block); only the TrackRows below are expensive, and those were
already gated by the same virtualWindow indices we keep using for
ensureTracksLoaded — so mounts stay bounded to the viewport as
before, only the skeleton rows are now fully rendered.

list and grid modes are unchanged — they still slice windowedItems
and use the virtual spacers as before.

cc
2026-04-21 13:36:43 -06:00
vicrodh 39abb14c05 feat(playlist-detail): consume backend is_network_mount flag
Frontend half of the network-mount detection. PlaylistLocalTrack
(from v2_playlist_get_local_tracks_with_position) and DisplayTrack
both gain an isNetworkMount field populated from the new
local_tracks.is_network_mount column.

isTrackAvailable now checks the flag first and only falls back to
the string heuristic when the flag is missing — either because the
row predates the migration and hasn't been re-scanned yet, or because
the track was loaded on a platform where backend detection isn't
wired (macOS, Windows).

Result: the sandbox case the user flagged — GVFS-mounted NAS share
bind-mounted at /home/user/music — now properly marks tracks as
offline-unavailable when the wire is cut, where the previous
string-only heuristic would have treated them as local.

cc
2026-04-21 13:29:14 -06:00