Inlines bodies of library_scan_impl, library_stop_scan_impl,
library_scan_folder_impl, library_clear_impl, library_update_album_metadata_impl,
library_write_album_metadata_to_files_impl, and library_refresh_album_metadata_from_files_impl
from library/commands.rs directly into the v2 wrappers in commands_v2/legacy_compat.rs.
Moves their helpers (process_cue_file, apply_sidecar_override_if_present,
compute_track_artist_match, normalize_library_path) alongside the inlined bodies.
Deletes the corresponding legacy #[tauri::command] _impl definitions and
the dead public command siblings (library_scan, library_stop_scan,
library_scan_folder, library_clear, library_update_album_metadata,
library_write_album_metadata_to_files, library_refresh_album_metadata_from_files)
that were only ever called from the deleted _impl variants.
Updates library/mod.rs to drop the corresponding re-exports.
Step 8 of legacy cleanup, group A.
cc
Inlines `check_network_path` from crate::network::commands into
commands_v2/legacy_compat.rs. The legacy command was a one-line wrapper
over crate::network::is_network_path; the inlined v2 form calls
is_network_path directly with no behavior change.
Deletes the legacy #[tauri::command] definition in network/mod.rs.
Other tauri commands inside crate::network::commands remain (they have
no v2 delegates and keep being exported as their own legacy commands;
they will be addressed in a separate cleanup step if needed).
No frontend invoke changes required.
Step 6 of the legacy cleanup plan, group G (network path).
cc
Inlines the bodies of `library_update_folder_settings` and
`get_track_by_path` from crate::library::commands::* directly into
commands_v2/legacy_compat.rs. Maps the legacy String error type to
RuntimeError::Internal at every return path.
Deletes the legacy #[tauri::command] definitions in
library/commands.rs. The data-layer methods on LibraryDatabase
(update_folder_settings, get_folder_by_id, get_track_by_path) are
unchanged.
No frontend invoke changes required.
Step 6 of the legacy cleanup plan, group F (folder settings + track
lookup).
cc
Inlines the body of `library_backfill_downloads` from
crate::library::commands::* directly into commands_v2/legacy_compat.rs,
mapping the legacy String error type to RuntimeError::Internal at every
return path. Adjusts the v2 return type to refer to
crate::library::BackfillReport (the canonical re-export from
qbz-library) instead of the deleted crate::library::commands path.
Deletes the legacy #[tauri::command] definition in
library/commands.rs. MetadataExtractor and BackfillReport are reused
unchanged from their crate::library re-exports.
No frontend invoke changes required.
Step 6 of the legacy cleanup plan, group E (backfill downloads).
cc
Inlines the bodies of `check_album_fully_cached` and
`check_albums_fully_cached_batch` from
crate::offline_cache::commands::* directly into
commands_v2/legacy_compat.rs, mapping the legacy String error type to
RuntimeError::Internal at the v2 boundary.
Note: v2_cache_notification_artwork is NOT a delegate — it lives in
commands_v2/helpers.rs and is a true helper used by other v2 commands.
The investigation list flagged it as a possible delegate, but
verification showed it owns its implementation.
Deletes the legacy #[tauri::command] definitions in
offline_cache/commands.rs. Other functions in the offline_cache::commands
module remain (still called from commands_v2/library.rs delegates,
which are step 8 scope).
No frontend invoke changes required.
Step 6 of the legacy cleanup plan, group D (offline cache).
cc
Inlines the bodies of `create_pending_playlist`,
`get_pending_playlist_count`, `queue_scrobble`, `get_queued_scrobbles`,
`get_queued_scrobble_count`, and `cleanup_sent_scrobbles` from
crate::offline::commands::* directly into commands_v2/legacy_compat.rs.
The legacy bodies were uniform: lock state.store, unwrap optional store,
delegate to a method on the store struct. Inlined verbatim with the
String error type mapped to RuntimeError::Internal at each return path.
The store-side OfflineStore methods (data layer) are untouched.
Deletes the legacy #[tauri::command] definitions in offline/mod.rs.
Other functions in the offline::commands module remain (still called
from commands_v2/library.rs delegates, which are step 8 scope).
No frontend invoke changes required.
Step 6 of the legacy cleanup plan, group C (offline scrobble/playlist).
cc
Inlines the bodies of `discogs_has_credentials`,
`discogs_search_artwork`, and `discogs_download_artwork` from
crate::library::commands::* directly into commands_v2/legacy_compat.rs.
The discogs delegates were single-line bodies (or trivial proxy logic);
DiscogsClient and library::get_artwork_cache_dir are reused as-is.
The String error type from the underlying DiscogsClient methods is
mapped to RuntimeError::Internal at the v2 boundary, matching the
existing v2 contracts.
Deletes the legacy #[tauri::command] definitions from
library/commands.rs. No frontend invoke changes required.
Step 6 of the legacy cleanup plan, group B (discogs).
cc
Inlines the bodies of `lyrics_get` and `lyrics_get_cache_stats` from
crate::lyrics::commands::* directly into commands_v2/legacy_compat.rs,
mapping the legacy String error type to RuntimeError::Internal at the
return boundary for v2_lyrics_get. Renames LyricsCacheStats to
V2LyricsCacheStats, hosted next to the inlined v2_lyrics_get_cache_stats.
Deletes lyrics/commands.rs entirely (also drops the dead
lyrics_clear_cache helper which had no callers; v2_lyrics_clear_cache in
commands_v2/library.rs already inlines its own logic).
The v2 functions retain their existing names, parameter shapes, and
Tauri::State signatures; only the body changes. No frontend invoke
changes required.
Step 6 of the legacy cleanup plan, group A (lyrics).
cc
Relocates CastState (struct + impl) from cast/commands.rs to
cast/state.rs, and DlnaState (struct + impl) from
cast/dlna/commands.rs to cast/dlna/state.rs.
Both types are consumed by commands_v2/library.rs (and the legacy
Tauri commands themselves) via the existing re-exports in
cast/mod.rs and cast/dlna/mod.rs. The re-exports are updated to
source from the new state modules, and each commands.rs re-exports
its state type so historical
crate::cast::commands::CastState / crate::cast::dlna::commands::DlnaState
paths continue to compile.
This unblocks future deletion of cast/commands.rs and
cast/dlna/commands.rs in a later cleanup step (the
#[tauri::command] functions in those files are already
unregistered and have zero Rust call sites).
cc
Relocates LibraryState (struct + impl), CleanupResult, BackfillReport,
LibraryAlbumMetadataUpdateRequest, and LibraryAlbumTrackMetadataUpdate
from library/commands.rs to a new library/state.rs module.
These types are consumed by commands_v2/library.rs,
commands_v2/legacy_compat.rs, commands_v2/mixtapes.rs,
commands_v2/link_resolver.rs, cast/commands.rs, and
session_lifecycle.rs. The existing pub use re-exports in
library/mod.rs are updated to source from state, and commands.rs
re-exports them in turn so historical
crate::library::commands::* paths (still used by
commands_v2/legacy_compat.rs, which is out of scope for this task)
continue to compile.
This unblocks future deletion of library/commands.rs once the
remaining #[tauri::command] definitions there are inlined into
their commands_v2 callers (separate task).
No serde shape changes; struct fields, derives and serde
attributes are preserved verbatim, so the JSON the frontend
receives is identical.
cc
Removes 11 #[tauri::command] functions that are unregistered and have
zero Rust call sites:
- pause_playback, resume_playback, stop_playback
- play_next_gapless, set_media_metadata
- get_audio_devices, reinit_audio_device
- get_audio_output_status, get_pipewire_sinks
- set_pipewire_default_sink, debug_get_cpal_devices
play_track, seek, and set_volume are preserved - they are still
consumed by src-tauri/src/api_server/mod.rs (REST endpoints for
the PWA remote). They will be migrated to call commands_v2/playback
or core_bridge in a later step.
Helper functions used solely by the deleted commands are also
removed: AudioDevice, AudioOutputStatus, PipewireSink structs,
get_pipewire_device_descriptions (linux + non-linux), and
cpal_device_name. Helpers reachable from play_track / seek /
set_volume / prefetch_track / get_playback_state (parse_quality,
limit_quality_for_device, download_audio, get_stream_info,
extract_audio_format_from_header, download_and_stream,
spawn_prefetch, prefetch constants and PREFETCH_SEMAPHORE) are
preserved.
cc
All 20 #[tauri::command] functions in this file are unregistered
(no longer in invoke_handler) and have zero Rust call sites. V2
equivalents in commands_v2/search.rs and commands_v2/catalog.rs
cover all use cases.
Removed:
- search_albums, search_tracks, search_artists, search_all
- get_album, get_featured_albums, get_track, get_artist
- get_similar_artists, get_genres
- get_discover_index, get_discover_playlists, get_playlist_tags, get_discover_albums
- get_artist_page, get_artist_basic, get_artist_detail
- get_artist_tracks, get_artist_albums, get_releases_grid
The corresponding pub mod search; declaration in commands/mod.rs is
also removed.
The SearchAllResults / MostPopularItem types (used by api_server's
REST /search/all endpoint, the PWA remote control surface) are
relocated into api_server/mod.rs since that is the only remaining
consumer; this avoids leaving a near-empty file just to host two
serde DTOs.
cc
These commands had v2_* equivalents registered alongside them; the
frontend has been migrated and no longer invokes them. The legacy
function definitions remain in their respective modules - they will
be removed in subsequent steps once we verify nothing internally
calls them.
cc
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
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
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
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
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
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
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
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
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
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
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
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
The test was added in 145974c29 when the default device name was a
const "QBZ Desktop". A later refactor (bf91822b4) replaced the const
with hostname-derived "Qbz - {hostname}" plus env-var/persisted-DB
overrides. This left the renderer fixture asserting a friendly_name
that no longer matches what default_qconnect_device_info() returns,
so refresh_local_renderer_id correctly returned None and the test
panicked.
Read the local device info inside the test and use those fields for
the renderer that should match. The test now exercises the contract
("when device_uuid is missing, fall back to a unique fingerprint
match against the local device info") regardless of host environment.
cc
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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