Remove gapless button from NowPlayingBar and place it in
SettingsView under Playback section. The toggle is now sensitive to
blocking settings: auto-disables when ALSA Direct backend or
streaming-only mode is activated. Gapless itself never blocks other
settings. Added inline experimental label and descriptive tooltips
with proper i18n for all 4 locales.
Add a toggleable gapless button next to the repeat button in the
NowPlayingBar. The setting persists in audio_settings.db and controls
whether the audio thread pre-queues the next track for gapless
transitions. Includes i18n keys for all 4 locales.
Backend: Add PlayNext command to audio thread that appends decoded
sources to the existing Rodio Sink for seamless transitions. The audio
thread detects when ~5s remain and signals gapless_ready via SharedState.
Position-based transition detection updates track metadata when the
queued source begins playing.
Frontend: playerStore detects gapless_ready flag, invokes
play_next_gapless (cache-only lookup), and handles transitions by
advancing the queue without stop/play cycle. playbackService gains
gaplessTransition option to skip stop_playback when backend already
transitioned.
Gapless is only eligible when: same sample rate/channels, cached data,
Rodio backend (not ALSA Direct), not streaming, not a user skip.
Falls back to normal stop-play flow when ineligible.
Replace static ReplayGain-only normalization with real-time loudness
analysis that works with any audio source including Qobuz streams.
New components:
- DynamicAmplify: Source wrapper that reads gain from shared atomic
with 50ms linear ramp to prevent clicks on gain changes
- AnalyzerTap: Source wrapper that batches samples and sends them
to the analyzer thread via bounded channel (never blocks audio)
- LoudnessAnalyzer: Background thread computing EBU R128 integrated
LUFS, first measurement at ~10s, refinement every ~5s
- LoudnessCache: SQLite persistence for computed LUFS values so
replayed tracks get instant gain application
Pipeline when normalization ON:
Decode -> Diagnostic -> AnalyzerTap -> DynamicAmplify -> Visualizer
Pipeline when normalization OFF (bit-perfect, unchanged):
Decode -> Diagnostic -> Visualizer
Re-enable normalization toggle in NowPlayingBar UI.
ReplayGain-only normalization is not useful for Qobuz streaming
(streams lack RG tags). Icon will be re-enabled once EBU R128
real-time analysis is implemented.
Adds 3-state normalization icon between fullscreen and volume controls.
Uses mask-image SVGs for theme-compatible coloring. Reduces song card
min-width by 72px to accommodate the new control.
Add normalization_gain field to SharedState and PlaybackEvent so the
frontend can display whether normalization is actively applied to the
current track. The gain factor is stored as AtomicU32 (f32 bits) and
emitted with each playback:state event.
cc
Add ReplayGain-based volume normalization to the audio pipeline.
When enabled, extracts ReplayGain track gain/peak tags from audio
file metadata (Vorbis comments, ID3v2 TXXX) and applies a gain
factor via rodio's Amplify wrapper.
Pipeline order: Diagnostic (raw) -> Amplify (normalization) -> Visualizer
When normalization is OFF (default), no Amplify wrapper is inserted
and the pipeline remains 100% bit-perfect.
- New module: audio/loudness.rs (extraction + gain calculation)
- New settings: normalization_enabled, normalization_target_lufs
- New Tauri commands: set_audio_normalization_enabled/target
- Supports -14 LUFS (default), -18 LUFS, -23 LUFS targets
- Clipping prevention using peak metadata
- Gain preserved across resume/seek operations
cc
DAC badge tooltip now says "Active" instead of "Bit-perfect" since
PipeWire passthrough is best-effort, not guaranteed bit-perfect like
ALSA Direct hw:X,Y. Added a brief note in Settings when DAC Passthrough
is enabled clarifying the distinction. Also i18n'd the hardcoded
dacPassthroughTooltipOverride string in SettingsView.
Moved all hardcoded English/Spanish tooltip text to i18n keys
under audioBadges section in all 4 locale files.
Removed dead dacTooltip derived property (unused in template).
Lock-free OR-mask analysis of f32 samples converted to i32,
bypassing PipeWire processing entirely. DiagnosticSource wraps
the audio source at decode level for both rodio and ALSA Direct
paths. Tauri commands: start_bitdepth_capture / stop_bitdepth_capture.
cc
decideLaunchModals() was racing activate_user_session() because
setLoggedIn() triggers the $effect before the backend store opens.
Preferences defaulted to showWhatsNewOnLaunch=true, ignoring the
user's saved setting.
Add sessionReady flag set after activate_user_session completes.
The $effect now waits for session + home view before running.
Also reset all update state on logout for clean re-login cycle.
cc
+layout.svelte: $isLoading inside $derived() -> isReady() function
HomeView.svelte: $t() inside $derived.by() -> getGreetingText() function
These caused vite-plugin-svelte CSS extraction to fail, cascading
PostCSS errors to unrelated components during HMR.
+layout.svelte: $isLoading inside $derived() -> isReady() function
HomeView.svelte: $t() inside $derived.by() -> getGreetingText() function
These caused vite-plugin-svelte CSS extraction to fail, cascading
PostCSS errors to unrelated components during HMR.
ImmersiveHeader splitTabs used $t() inside $derived(), causing
vite-plugin-svelte CSS extraction to receive raw script content
instead of CSS — cascading PostCSS errors to unrelated components.
ShortcutInput had the same pattern for displayShortcut.
Both now use the labelKey/function pattern instead.
initUpdatesStore() set its initialized flag before the async preference
fetch completed. If get_update_preferences failed (backend store not
yet ready), preferences stayed at defaults (both true) and were never
re-fetched. This caused:
- "Show What's New" toggle appearing enabled despite being disabled
- "Check for updates on launch" toggle not persisting
- Changelog modal reappearing on every restart
Changes:
- Split initialized flag into versionLoaded/prefsLoaded so preference
loading retries until the backend store is ready
- Call refreshUpdatePreferences() in decideLaunchModals() to always
get fresh data before making modal decisions
- Call refreshUpdatePreferences() in handleLoginSuccess() after
session activation so SettingsView reflects saved values
cc
init_at() loaded credentials from the user-scoped SQLite cache but
never called restore_token() on the in-memory client. The client
remained empty, so get_status() always returned disconnected after
app restart. Also restore the enabled flag from persisted settings.
Fixes#54
cc
On release events pkgrel defaults to 1. On manual dispatch it can be
set to any value (default 1), allowing same-version rebuilds like
1.1.9-2 without editing the workflow.
initUpdatesStore used Promise.all for get_current_version and
get_update_preferences. Since preferences need the SQLite store
(only available after login), the failure killed both promises,
leaving currentVersion as empty string.
Split into independent calls so the version (a compile-time constant
that never needs a session) is always available regardless of
preference store state.
Remove LIBSQLITE3_SYS_USE_PKG_CONFIG and related env vars that forced
linking against system SQLite 3.37.2 on core22. The rusqlite bundled
feature compiles SQLite 3.38+ from source, which provides the
sqlite3_error_offset symbol needed by rusqlite 0.31.
The button previously failed silently when version was unavailable or
the GitHub API request failed. Now shows loading state while fetching
and a toast notification on failure.
The Svelte 5 CSS extraction breaks when t is used as a callback
parameter inside $derived() or $derived.by() blocks. Renamed to
track, item, or lt in PlaylistDetailView, FavoritesView,
PlaylistSuggestions, and PlaylistTagFilter.
Split import into two-step flow: Fetch (preview) then Import.
After fetching, user can edit the playlist name and optionally
assign it to a folder before importing. Backend already supported
name_override — this just exposes it in the UI. Also i18n all
new strings and existing hardcoded modal text.
When the user changes any audio setting (output device, exclusive mode,
DAC passthrough, backend type, ALSA plugin), the stream is reinitialized.
Previously playback would die and the user had to manually skip
forward/backward to recover. Now playback automatically resumes at the
same position after reinit, since the backend already preserves position
and audio data across ReinitDevice.
On mount, check if a valid cache exists (< 5 min, same genre filter).
If valid, restore all section data instantly and scroll position,
skipping all API calls. Cache is saved automatically when all
sections finish loading. Invalidated on genre filter change or
home settings change.
Replace the blocking overlay system with per-section skeleton
placeholders that appear immediately and swap to real content
as each section loads independently. Download status checks are
now fire-and-forget (no longer block section display). Remove
all DEBUG-43 console warnings. Remove unused sidebarExpanded prop.
Module-level in-memory cache that stores all 11 Home sections'
resolved data with TTL (5 min) and genre filter snapshot validation.
Follows the same pattern as searchState.ts.
ReinitDevice was resetting position to 0 and clearing audio data,
making it impossible to resume after changing audio settings.
Now uses pause_playback_timer() to capture real-time position and
keeps audio data intact. The existing Resume handler already
recreates the engine, re-decodes, and seeks to the saved position.
Removes unnecessary stop_playback calls from frontend that were
destroying the preserved state after backend/device changes.
Visualizer FFT thread, remote control API server, and Last.fm session
restore were all initializing before user session activation, causing
errors ("No active session") and wasting resources.
Move all three to run after activate_user_session:
- Visualizer: add started guard to prevent double-start
- Remote control: sync_server now has per-user settings available
- Last.fm: restoreLastfmSession moved from bootstrapApp to handleLoginSuccess
Move setLoggedIn() before the await activate_user_session() in
handleLoginSuccess. LoginView already activates the session for
auto-login, so the second call is idempotent. By setting the auth
state immediately, the template switches from LoginView to the app
without the ~475ms flash of the login form during the await.
The frontend JS console is inaccessible when the UI blanks, so all
diagnostic logging must go through the Rust backend (terminal).
Promote get_featured_albums, get_discover_index, get_artist_basic to
log::info with [DEBUG-43] prefix. Add OK/FAILED result logging to
get_album, get_track, get_featured_albums, get_artist_basic, and
get_discover_index command handlers.
This ensures the terminal shows the full sequence:
reco_get_home_ml → featured_albums(x5) → discover_index → tracks → albums → artists
Log every decision point in loadHome():
- totalVisibleSections count
- mlPromise invoke/resolve/reject with seed counts
- fetchDiscoverData invoke/result with container keys
- checkAllSectionsReady transition with hasContent/error state
- catch block entry (which blocks all content rendering)
Backend: reco_get_home_ml logs call, success with counts, and
explicit error when RecoState.db is None.
Add [API] log lines with HTTP status to all client methods for
debugging blank views (#43). Handle 404 explicitly in get_track
(returns TrackUnavailable) and get_album (returns ApiResponse)
instead of attempting JSON deserialization of error responses.
Missing .catch() on 9 .then() chains in HomeView meant any rejected
promise (e.g. 404 from a deleted track) would prevent markSectionFinished()
from being called, leaving the loading overlay permanently visible.
Each catch now logs the error, clears the loading flag, and calls
markSectionFinished() so the overlay always dismisses.
Switch from hi-res-gray.svg back to the gold hi-res.svg. Add a
sub-pixel drop-shadow filter to reinforce thin text strokes against
the gold gradient, fixing chromatic anti-aliasing blur at 24px.
add_favorite and remove_favorite now invalidate the corresponding
album/track/artist entry in the API cache after a successful API call.
This prevents stale JSON (missing the updated favorite status) from
being served on the next detail fetch. Added targeted invalidate_album,
invalidate_track, and invalidate_artist methods to ApiCache.
Genre lists from Qobuz are near-static, so caching them avoids
unnecessary API calls. Added cached_genres table with GENRE_TTL_SECS
(7 days), get/set methods, and wired cache into get_genres() command.
Also updated cleanup_expired and clear_all to include the new table.
Replace 30+ manual .header("X-App-Id")/("X-User-Auth-Token") calls with
two helper methods:
- api_headers(): always sends X-App-Id, optionally X-User-Auth-Token
- authenticated_headers(): requires auth, fails if not logged in
This fixes the root cause of #43 (get_album/get_track missing auth token)
by making it impossible to forget headers. Also removes DEBUG-43 verbose
response body logging from client.rs since the root cause is resolved.
These endpoints were missing the X-User-Auth-Token header, causing
Qobuz to return 404 for all album and track detail requests. This is
the root cause of issue #43: blank album pages, "Unknown Artist" and
"0:00" in Recently Played / Continue Listening.
List endpoints (get_featured_albums) and playlist endpoints worked
because they either don't need auth or already included the token.
Closes#43
Show a user-friendly i18n message with back button when an album loads
without tracks, instead of displaying a blank track list. Uses
album.loadError key in both en.json and es.json.
Add console.warn logging with [DEBUG-43] prefix to:
- handleAlbumClick: album ID, API response, converted data, tracks count
- AlbumDetailView onMount: album state and track availability
- HomeView fetchFeaturedAlbumsSingle: genre ID and result count
Uses console.warn for better WebKitGTK log visibility.
Add info-level logging with [DEBUG-43] prefix to:
- get_album/get_track commands: cache hit/miss/error, API errors
- API client get_album/get_track: request URL, response status, response
body preview (first 500 chars), parse/deserialize errors
This traces the complete detail fetch path to diagnose why album/track
detail views fail for specific users while list endpoints work fine.
Add validateAndCleanStoredGenreIds() to detect and remove stale genre IDs
from localStorage that do not exist in the loaded genre list. This prevents
the Qobuz API from returning empty results when localStorage contains
outdated genre IDs from previous sessions.
The validation runs after loading genres from both cache and API paths.
PlaylistDetailView used t as a callback variable inside $derived(),
which shadows the i18n t store and breaks Svelte CSS extraction
globally. Renamed to track.
Saves the active user_id to a flat-path file on activate_user_session
and clears it on deactivate_user_session (explicit logout). On startup,
if saved credentials and a last_user_id both exist, the per-user session
is restored before loading ToS acceptance or attempting auto-login.
This fixes the chicken-and-egg problem where ToS acceptance lived in the
per-user DB but was needed before login to allow auto-login.
get_stats() now respects the show_in_library setting so offline cached
tracks don't inflate the Local Library counter when the user has chosen
not to show them there.
The "Library" section had a single lonely setting. Moved it into the
Offline Playback section and removed the now-empty Library section
along with its nav entry.
The toggle for showing offline cached tracks in the Local Library view
was removed from the UI but the backend logic remained. Re-added it to
the Offline Playback settings section. Also renamed the section from
"Offline Library" to "Offline Playback" in both EN and ES locales.
OfflineCacheState.cache_dir was set during new_empty() to the flat path
(~/.cache/qbz/audio/) and never updated when init_at() opened the DB at
the user-scoped path. This caused track files and artwork to be
read/written at the wrong location. Wrapped cache_dir in Arc<RwLock<>>
and update it during init_at().
Moved initOfflineCacheStates, initPlaybackPreferences, initBlacklistStore
from onMount (before login) to handleLoginSuccess (after session active).
Downgraded pre-login "No active session" errors to debug level for
qobuzLegalStore, TitleBar tray settings, and offlineStore.
blocking_lock() panics when called from within the tokio async runtime.
Changed init_at() and teardown() to async fn with .lock().await for all
8 modules using tokio::sync::Mutex (library, reco, api_cache,
artist_vectors, musicbrainz, listenbrainz, offline_cache, lyrics).
Updated activate/deactivate_user_session to .await these calls.
Swap localStorage.getItem/setItem/removeItem for getUserItem/setUserItem/
removeUserItem from userStorage.ts across all stores, services, and view
components that use qbz- prefixed keys.
Global keys (theme, zoom-level, locale) remain as direct localStorage
since they're read before login during bootstrap.
- userStorage.ts: per-user localStorage scoping utility with migration
- authStore: add userId to UserInfo interface
- LoginView: extract user_id from login/auto_login responses
- +page.svelte: call activate_user_session after login,
deactivate_user_session before logout, set up per-user localStorage
LoginResponse now includes user_id from the Qobuz API session.
activate_user_session: called after login with user_id, runs one-time
flat-to-user migration, then initializes all 22+ per-user stores at
~/.local/share/qbz/users/{uid}/ and ~/.cache/qbz/users/{uid}/.
Also runs deferred subscription purge check.
deactivate_user_session: called before logout, tears down all per-user
stores to close database connections and clears the active user.
Phase 1 (device-level): Read audio/tray settings from flat path once
for player and tray init. Initialize cast, DLNA, API server.
Phase 2 (per-user): All 22 per-user states init as new_empty().
They get populated after login via activate_user_session.
Subscription purge check deferred to activate_user_session.
Modify callers in commands/, cast/, library/, and lib.rs to unwrap
Option<Store> with appropriate error messages. Fix drop(db) to
drop(guard__) for correct mutex release.
One-time migration moves all existing DB files and cache directories
from the flat layout into users/{uid}/ subdirectories. The first user
to log in after the update inherits all existing data. A .migrated
marker file prevents re-running.
Handles WAL/SHM journal files, cross-device moves via copy+delete
fallback, and recursive directory copying.
Central path provider that holds the current user_id behind RwLock.
Provides user-scoped data and cache directory paths:
- ~/.local/share/qbz/users/{uid}/
- ~/.cache/qbz/users/{uid}/
Registered as Tauri managed state. No behavior change yet -
user paths are not used by any module until activate_user_session
is implemented.
Replace the non-functional '...' button with a dropdown menu
containing Fullscreen, Maximize/Restore, Minimize, and Exit
Immersive options. Menu uses the same glassmorphism style as the
player bar for visual consistency.
On open, restore the preferred view from localStorage. When set to
'remember', save the current view on every view/tab switch via header
or keyboard. When set to a specific view, always open to that view.
Add dropdown in Settings > Appearance to choose which view immersive
mode opens with. Options: Remember last used (default), 6 focus views,
and 4 split-mode combinations. Persisted via localStorage.
Change Source<Item = i16> to Source<Item = f32> throughout.
Remove i16-to-f32 normalization since f32 samples are already in
[-1.0, 1.0] range. Update tests to use f32 sample data.
PlaybackEngine::append() now accepts Source<Item = f32>.
ALSA Direct thread uses f32 buffer and calls write_f32().
New write_f32() method in AlsaDirectStream converts f32 samples to
hardware format with full 24-bit precision:
- S24_3LE: f32 -> 24-bit packed 3 bytes (SMSL/Topping DACs)
- S32LE: f32 -> i32 full range
- S24LE: f32 -> 24-bit in i32 container
- S16LE: f32 -> i16
- FloatLE: direct write
Old write(&[i16]) kept for safety during migration.
Update AudioSpecs, decode_with_symphonia(), decode_with_fallback(),
wrap_source closure, and all Source boxing casts to use f32 samples.
This ensures 24-bit audio data flows through the player without
truncation to 16-bit.
Change SampleBuffer<i16> to SampleBuffer<f32> in the vendored rodio
decoder. This preserves 24-bit audio data (f32 has 24 bits of
significand) instead of truncating to 16-bit at decode time.
Affects: symphonia.rs (SampleBuffer, Iterator::Item)
mod.rs (Decoder, LoopedDecoder, DecoderImpl Iterator::Item)
Subscribe to favoritesStore in ArtistDetailView to display filled
heart icon when a track is already favorited. The button now toggles
(add/remove) instead of only adding, with proper loading state.
When user presses STOP then PLAY, playback now resumes the current
queue track instead of doing nothing. Added onResumeFromStop callback
in playerStore that queries the backend queue state and re-plays the
current track. The audio cache ensures instant playback within the
same session.
When activeView is 'album' but selectedAlbum is null, show an error
message with a back button instead of a blank page. Also add detailed
console logging in handleAlbumClick to help diagnose the root cause
for users who can reproduce.
Lyrics sidebar was staying open behind the immersive player, causing
duplicate CPU usage for synced lyrics rendering. Now hideLyricsSidebar
is called alongside closeAllMenus when opening fullscreen or focus mode.