The UI is React 19 + Tailwind CSS 4. The provider tree is mounted in App.tsx; the layout shell is in AppLayout.tsx.
Three-column flex row:
ββββββββββββ¬βββββββββββββββββββββββββββββββ¬βββββββββββ
β Sidebar β Center column β Right β
β β ββββββββββββββββββββββββββ β panel β
β - Home β β TopBar (search + nav) β β β
β - Lib β ββββββββββββββββββββββββββ€ β Now β
β - β¦ β β β β Playing β
β - Pls β β Scrollable content β β or β
β β β β β Queue β
β β ββββββββββββββββββββββββββ β or β
β β β Lyrics β
ββββββββββββ΄βββββββββββββββββββββββββββββββ΄βββββββββββ€
β PlayerBar (bottom, full width) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββThe right panel is a flex sibling of the center column, not an overlay β opening it shrinks the content area Spotify-style. The center column has min-w-0 so wide tables collapse instead of pushing the panel off-screen. Only one of the three right-panels is mounted at a time (mutex via PlayerContext).
Every full-page view (Home, Library, Liked, History, Playlist, Album/Artist/Genre detail, Statistics, Wrapped, Settings) is React.lazy()-loaded. The Suspense fallback in AppLayout is ViewSuspenseFallback β a layout-shaped skeleton (role="status" / aria-busy="true") instead of a spinner that read as a blank screen. To make the fallback rarely fire at all, AppLayout schedules a requestIdleCallback after first mount that warm-imports every lazy view module; once those imports resolve they're cached in the module registry, so a sidebar click usually skips Suspense entirely.
Per-view data fetches initialise their isLoading state to true (not false) so the first render paints a skeleton matching the view's shape rather than flashing the empty-state for the frame between mount and the first effect tick. Detail pages (Album/Artist/Genre) share DetailViewSkeleton; list-shaped pages use inline <β¦Skeleton> components colocated with their view file.
NowPlayingPanelβ large artwork, clickable artists, "About the artist" section populated from the Deezer + Last.fm caches, and a "Next in queue" teaser with an "Open queue" link that hands the right slot off toQueuePanel. Lightbox on cover click.QueuePanelβ current queue with drag reorder, jump-to-track, clear queue.LyricsPanelβ synced or static lyrics with auto-scroll.NowPlayingChevronTabβ right-edge floating tab visible only when no panel is open.
FullscreenNowPlaying is an Apple-Music-style overlay (fixed inset-0 z-100) that turns the current track into the focal point: huge centred cover, large title + clickable artist + album, the same PlaybackControls / ProgressBar / VolumeControl the bottom bar uses, and a like toggle. Background is a blurred copy of the artwork (with a 55% black wash over it) so the view stays visually anchored to the track without any extra theming work.
Two entry points in the PlayerBar: clicking the cover in the bottom bar (mirrors Spotify) or the dedicated Maximize2 icon next to the lyrics toggle. The header of FullscreenLyrics also carries a Maximize2 button (symmetric to the Mic2 "open lyrics" button in FullscreenNowPlaying) so the user can round-trip Immersive β Lyrics without leaving fullscreen β the parent flips the fullscreen mutex so only one overlay is ever mounted. Closes on Escape or the X button. State is local to the bar β no PlayerContext involvement because nothing else needs to know about the overlay.
Transition hygiene β both overlays paint a solid bg-zinc-950 on the outer wrapper from the first frame; the animate-fade-in keyframe lives on the inner backdrop + foreground layers, not the wrapper. Without that opaque base the wrapper's own opacity ramp (0 β 1 over 300 ms) would let the page underneath (search bar, sidebar, cards) bleed through during the transition. Same reason FullscreenLyrics is portalled to document.body from LyricsPanel via createPortal: the panel itself is a motion.aside that animates opacity 0 β 1 on mount, and a nested overlay would inherit the parent's opacity (the immersiveβlyrics direction would have flashed the home view through the overlay until the side panel finished its spring tween). Portalling moves the rendered subtree to the document root while the panel keeps owning the fetch + parse state.
MiniPlayerApp + MiniPlayer ship a Spotify-style always-on-top widget. Launched from the picture-in-picture button in the PlayerBar via lib/miniPlayer.ts::openMiniPlayer.
- Window β second
WebviewWindow(labelmini), default 280Γ380 withdecorations: false(we render our own top bar) andalwaysOnTop: true. Hides the main window on open; the mini's Maximize button restores it and closes the mini. - Persistent bounds β position + size are persisted in
app_setting['mini_player.bounds'](JSON blob, machine-level) via debouncedonMoved/onResizedlisteners inMiniPlayer.tsx(300 ms after the last gesture so SQLite isn't hammered at 60 Hz while dragging). On open,miniPlayer.ts::openMiniPlayerrestores the saved rectangle when it still overlaps an available monitor by at least 80 px on both axes (availableMonitors()check guards against monitor disconnects / resolution changes). Otherwise it falls back to anchoring bottom-right of the primary monitor (currentMonitorβ physical size Γ· scale factor β logical px) with a 24 px edge margin so the OS taskbar / Dock isn't covered. - Routing β same Vite bundle, branched in
main.tsxon?mini=1so the mini boots into a stripped-down provider tree (Theme + Profile + Playeronly β noLibrary/Playlistsince the widget never browses). - Cover-derived background β
lib/dominantColor.tsdraws the artwork onto a 64Γ64 canvas, samples every 4th pixel, skips near-monochrome runs (white margins, black bars) so the average reflects the real hue, and produces a 3-stop gradient applied to the window background. - Hover overlay controls β shuffle / prev / play (white round Spotify-style) / next / repeat fade in over the cover; idle state shows just the artwork.
- Drag region β
data-tauri-drag-regionon the central dot strip, plus an explicitgetCurrentWindow().startDragging()onMouseDownas a belt-and-suspenders fallback for the Windows hit-test races. Requirescore:window:allow-start-draggingin the capability (not incore:default). - Pin toggle β runtime
setAlwaysOnTop(bool); emerald when active. - Interactive seek bar β slim white bar at the bottom, click/drag to scrub. Same
pointer capture+ localdragMspattern as the mainProgressBar. Thumb + timestamps fade in on hover so the idle widget stays minimal. - Capabilities β the mini-player's window label is added to
capabilities/default.jsonso it inherits every command the main window has access to (no duplicated capability file, no per-window permission pruning).
To hide the cold-start delay (Windows SmartScreen / Defender scanning every freshly-extracted DLL on the very first launch after install, plus the setup() chain in lib.rs β opening app.db + running migrations, creating the default profile, cold-initialising cpal/WASAPI), the main window is created with "visible": false and a small secondary window (label: "splashscreen", 360Γ240, opaque #121212, decorations off, always-on-top, off the taskbar) shows a WaveFlow logo + indeterminate progress bar while the backend boots and the React bundle parses.
The static HTML lives in public/splash.html (no JS, inline SVG logo, single CSS animation) so it paints the instant the WebView2 process spawns. The splash β main handoff is driven from the backend β the frontend's ReadySignal component emits app://ready after React's first commit (via useEffect, not requestAnimationFrame β WebKitGTK 2.52 suspends rAF callbacks while a window is visible: false), and lib.rs's setup installs an app.listen("app://ready", β¦) listener that calls reveal_main_close_splash: show main first, set focus, then close the splash so the desktop is never visible between the two. A 15 s safety-net timer + bounded retry loop (10 attempts, 250 ms backoff) revives the handoff if the event never arrives. The mini-player webview branches out via ?mini=1 and skips the dance.
Why backend-driven: v1.1.0 ran the handoff entirely in main.tsx via requestAnimationFrame + IPC window.show() + splash.close(). On Linux WebKitGTK 2.52+ the heavy first-launch init (migrations + DB pool + WebKit profile dir) raced the rAF window, the show()/close() could fire on a non-ready webview, and the user was stuck on an eternal splash (issue #42). Native-side ownership + an explicit "DOM committed" signal is robust to that race.
Splash window background is opaque on purpose: "transparent": true forces an alpha-capable EGL config that some WebKitGTK builds reject, doubling the EGL failure surface (see also the AppImage incompatibility note for WebKitGTK 2.52+).
Quick playback controls (Play/Pause, Previous, Next, Quitter). Close-to-tray is the default close behaviour β the WindowEvent::CloseRequested handler hides the window unless the tray "Quitter" item armed QuitGate. Tray ID is waveflow.
StatisticsView.tsx projects from play_event:
- KPIs (total listening time, distinct tracks/artists/albums, completion rate)
- GitHub-contributions-style yearly heatmap (
Heatmap.tsx) β 53Γ7 grid pinned to the past 12 months regardless of the period selector, intensity bucketed in quartiles against the local max so the gradient stays meaningful for both light and heavy listeners. Reusesstats_listening_by_daywithrange="1y"; no new backend command. - Listening-by-day and listening-by-hour bar charts
- Top tracks / artists / albums for the selected window (7d / 30d / 90d / 1y / all)
- JSON export β
export_stats_json(range, target_path)(commands/stats.rs) bundles the active range's overview + top 100 tracks/artists/albums + listening-by-day + listening-by-hour into a versioned (schema_version: 1) pretty-printed JSON file. The Rust side writes the file directly viaspawn_blockingso we don't depend ontauri-plugin-fsjust to round-trip a string. Frontend trigger is the Download button next to the range selector in the header.
WrappedView.tsx is a year-in-review experience modelled on Spotify Wrapped, built entirely from local play_event rows β no network call, no external service. Three backend commands in commands/wrapped.rs:
available_wrapped_years()β distinct years that have at least one play event, sorted descending. Used to gate the HomeView banner and populate the in-overlay year picker.get_wrapped(year)β bundles every aggregate into a single payload: overview (plays / minutes / unique tracks / artists / albums), top 10 tracks + artists + top 5 albums (reusing the row shapes fromcommands/stats.rsso the artwork resolver works unchanged), per-month + per-hour histograms, most active day, mood profile, first listen of the year, and longest consecutive-day listening streak.wrapped_current_year()β server-sideLocal::now().year()so the frontend doesn't depend on the JSDatefor the fallback default.
Year bounds are computed in local time (Jan 1 00:00 β Dec 31 23:59:59, exclusive upper) so a play at 23:59 on Dec 31 lands in the right year regardless of UTC offset. The mood profile uses listening-weighted averages (weight = listened_ms) so a 4 min play of a fast track counts ~16Γ a 15 s skip of a slow one β otherwise a hate-skip collection would skew the BPM mean. The energy label is derived from BPM buckets server-side (< 80 β chill, < 110 β warm, < 135 β groove, < 160 β energetic, else fire) but is localised on the frontend via a fixed dictionary so we never ship copy from Rust.
The streak walks the distinct-day list once and tracks the longest run of dates that increment by exactly one day. Bounded at 366 rows per year β no fancy gaps-and-islands SQL needed.
Frontend overlay (fixed inset-0 z-100, same pattern as FullscreenNowPlaying) ships 10β12 auto-advancing slides at ~6.5 s each. Slides without data are filtered out before the rotation starts β no analysed tracks β no mood slide; no streak β₯ 2 days β no streak slide β so a brand-new profile with three plays still gets a coherent (if short) experience. Top-of-screen progress segments + space-to-pause + arrow-key navigation match Instagram / Snapchat story conventions.
The HomeView entry point is a gradient banner above the Mood Radio grid, gated by useWrappedBannerVisibility β three modes persisted in profile_setting['wrapped.banner_visibility']:
auto(default) β shows the banner only during the Wrapped season (December 1 β January 31, local time), matching Spotify Wrapped's release cadence so the recap stays an event rather than permanent dashboard furniture. The rest of the year the banner is hidden but the WrappedView remains reachable.alwaysβ render wheneveravailable_wrapped_yearsreturns at least one year. Power-user opt-in for people who want their recap pinned year-round.neverβ never on Home. The view itself stays reachable.
The banner also exposes a per-recap-year dismiss button (the X in the top-right corner) that writes profile_setting['wrapped.dismissed_year'] so a quick close hides the banner for that year only β next year's recap re-appears automatically. Mode is configured from Settings β Appearance via WrappedBannerCard. The card also surfaces the current season status (seasonActive / seasonIdle) when auto is selected so the user understands why the banner is or isn't on their Home right now.
The full banner stack β visibility check + available_wrapped_years length β collapses to nothing when either condition is unmet, so an empty library never paints the banner regardless of mode.
The Share button in the overlay top bar opens a two-action menu: Save as PNG (native save dialog β file on disk) and Copy image (clipboard via navigator.clipboard.write + ClipboardItem). Both go through lib/wrappedCard.ts, a pure Canvas 2D renderer that produces a 1080Γ1920 portrait PNG mirroring the overlay's visual style β radial-gradient backdrop sampled from the same accent palette, year + total minutes as marquee elements, top 5 tracks with cover thumbnails, mood + streak strip, "Powered by WaveFlow" footer. Text uses the WebView's native font stack so we don't ship a font file with the bundle. The "save" path serialises the PNG bytes through the IPC channel (save_share_image(bytes, target_path), shared with the Now Playing card) and writes via spawn_blocking β no tauri-plugin-fs dependency. The "copy" path stays in the browser and works on Chromium-based WebView (Edge on Windows, WKWebView on macOS); WebKitGTK on Linux historically refused image/png clipboard writes, so the error is surfaced rather than silently no-op'd.
Same Save / Copy pattern as Wrapped, but applied to the currently-playing track. The Share button in the FullscreenNowPlaying top bar generates a 1080Γ1080 square PNG via lib/nowPlayingCard.ts β the cover artwork is drawn full-bleed under a dark wash for the background, then again as a centred 580 px tile with rounded corners + drop shadow, followed by title + artist + album text. The bottom of the card carries a thin accent strip in the artwork's dominant colour (sampled via the existing lib/dominantColor.ts) so each card visually nods to its source cover. Backend writes go through the same save_share_image Tauri command as Wrapped β the IPC channel is feature-agnostic so future share card flows (album, playlist) can reuse it without new commands. Disabled when no track is playing.
Music browsing views (Home, Library, Playlist, Album, Artist, Liked, Recent, Statistics) render full width inside the center column β no max-w-* cap. The p-8 gutter on the page scroller (AppLayout.tsx) is the only horizontal breathing room. On a 2.5K display the table area gains ~800 px over the previous max-w-6xl mx-auto constraint.
Form-style views (Settings, About, Feedback) keep max-w-4xl because dense forms read better with a comfortable line length.
Track tables themselves are borderless β no rounded-2xl border bg-white card wrapper. The page already provides the visual frame; nesting another card just shrinks every row by ~80 px and breaks the Spotify-style "rows on the page" feel. The column-header border-b is the only separator between header and rows.
- Virtual scroll β
@tanstack/react-virtualon every long list (tracks, queue, playlist contents, statistics rows). Tables share the page-level scroller viausePageScroll()and computescrollMarginfrom the parent's offset so the virtualiser knows where its content begins. Single Spotify-style scrollbar, no nested overflow. - Image cache β in-memory LRU (
lib/imageCache.ts) forconvertFileSrcresults so the same artwork URL isn't recomputed on every render. - Thumbnails β 1Γ and 2Γ covers generated by
thumbnails.rswithfast_image_resize(SIMD AVX/SSE/NEON depending on host) and served via the asset protocol.
Right side of PlayerBar is the highest-pressure real estate in the UI β every new feature wants an icon there. To keep the bar from running out of width on narrow windows, controls cluster by frequency:
| Tier | Controls | Where |
|---|---|---|
| Primary | Lyrics, Queue, Device picker, "β―", Volume, Mini-player, Immersive view, plus any pinned overflow item (A-B loop, Sleep timer, EQ presets). Every entry is opt-out from the user side via Settings β Playback (defaults match the pre-customisation layout β zero visible change after the upgrade). | Each button reads its visibility from usePlayerBarLayout (src/hooks/usePlayerBarLayout.ts). The same hook drives the live preview in the Settings panel. |
| Overflow | Playback speed (slider + presets), EQ presets, A-B loop, Sleep timer | MoreActionsMenu β "β―" popover; trigger auto-hides when every overflow entry is pinned. EQ presets share their inner EqPresetPanel body with the primary popover variant. |
| Pinnable | A-B loop, Sleep timer, EQ presets (promote each to primary independently) | Settings β Playback β "Player bar layout" β single panel covering every button + the cover-click action. |
The Settings panel (PlayerBarLayoutCard) replaces the three earlier per-feature toggles (sleep timer / A-B loop / audio-quality footer). Layout is read through usePlayerBarLayout and writes are persisted via setProfileSetting + a single waveflow:playerbar-layout-changed window event so every consumer re-reads in one go (the legacy per-feature events waveflow:sleep-timer-visibility / waveflow:ab-loop-visibility / waveflow:audio-quality-footer-visibility are still observed by the hook for back-compat with any external dispatcher).
Order is fixed β drag-to-reorder would add a sortable library dependency and a fairly minor UX gain since the player bar is small and the conventional left-to-right sequence (overflow β utility toggles β volume β window-management) is what users from Spotify / Apple Music already expect.
The cover thumbnail at the bottom-left of the player bar carries its own action (ui.cover_action, default immersive):
| Value | Behaviour |
|---|---|
immersive |
Open the full-screen Now Playing overlay (the pre-customisation default β Apple-Music style). |
now_playing |
Toggle the right-edge Now Playing panel β Spotify-style "click cover, see lyrics + cover wall". |
none |
No-op. Useful for users who keep mis-triggering it. |
When adding a new player-bar action: default it into the overflow menu first β promote to primary only when usage data or user feedback warrants it. Always wire it through PLAYER_BAR_LAYOUT_KEYS + the PlayerBarLayoutCard toggle grid so users can opt out from one place. The "β―" trigger auto-hides when its menu would be empty.
Volume control (VolumeControl) supports three input modalities: pointer drag on the track, keyboard arrows / Home / End when the slider has focus (5 % step, 0 / 100 jumps), and mouse-wheel scroll anywhere over the icon + track area (5 % step, wheel up raises). The wheel handler is bound through addEventListener('wheel', ..., { passive: false }) so it can preventDefault the underlying page scroll β React 17+'s JSX onWheel is passive and would let the track list behind the player bar scroll at the same time. Horizontal-only scrolls (deltaY === 0, e.g. trackpad sideways swipes) are ignored so they don't get treated as a volume-down tick.
Playback speed lives inside MoreActionsMenu (range slider + five presets) rather than a dedicated bar button β it's used too rarely to deserve a permanent slot. When speed β 1Γ, the "β―" trigger surfaces a compact 1.25Γ badge in emerald (same corner as the sleep-timer countdown β the countdown wins when both are active). See playback / Playback speed for the backend side.
A-B loop and Sleep timer are always available β they live in the "β―" overflow menu by default. The pin toggles let frequent users promote them to a primary slot on the bar so they're one click away. Both default to off:
| Setting key | Pinned button rendered in primary slot | Default |
|---|---|---|
ui.show_sleep_timer |
Moon icon (sleep timer menu) | off |
ui.show_ab_loop |
Repeat icon (A-B loop) | off |
When a pin is OFF, the entry stays in the overflow menu and the sleep-timer countdown badge surfaces on the "β―" trigger itself so the user keeps live feedback while the timer is armed. The PlayerBar listens to waveflow:sleep-timer-visibility / waveflow:ab-loop-visibility window events dispatched by the Settings toggle so the layout re-renders without a polling loop.
The overflow popover itself is capped at max-h-[calc(100dvh-7rem)] with overflow-y-auto overscroll-contain (MoreActionsMenu). On a 1080p display with nothing pinned, the stack (speed slider + 5-preset grid, EQ section, A-B loop row, sleep-timer 6-preset grid + end-of-track + custom-minutes form) would otherwise run past the viewport top. 100dvh rather than 100vh keeps the math right when Tauri window chrome hides on Linux/macOS fullscreen; overscroll-contain prevents wheel/touch scrolls from chaining to the page underneath.
The opt-in audio-quality footer (AudioQualityFooter, pinned via Settings β Appearance β Player bar layout) is a thin strip below the player bar that surfaces the source file specs in compact form (48 kHz Β· 256 kb/s Β· 6 Mo on the left, AAC Β· 24bit Β· 48kHz on the right; bitrates β₯ 1000 kbps render as Mb/s). When the engine is resampling β source rate β output device rate β the left chunk renders an arrow instead: 48 kHz β 44.1 kHz Β· β¦, so the user can spot the conversion at a glance without opening the popover. The arrow is gated on the device rate being known (the engine reports 0 before the first stream opens); otherwise we fall back to the source rate alone rather than printing a misleading 48 kHz β null. The Hi-Res pill surfaces when isHiRes accepts the source bit depth / sample rate combination.
HiResBadge is the green pill that decorates track rows, album grid tiles, and the player-bar metadata when the source qualifies as Hi-Res (isHiRes β β₯ 24-bit, β₯ 44.1 kHz) or as DSD (dsdLabel returns DSD64 / DSD128 / β¦). Three variants:
| Variant | Used in | Style |
|---|---|---|
overlay |
Album / artist grid covers (default). | Absolute-positioned pill in the cover's top-left corner with a drop shadow. |
inline |
TrackTable rows, sidebar lists. | Inline rounded pill next to the title. |
text |
Player bar β under the artist name. | Spotify-style minimal green uppercase text, no pill background, blends into the metadata stack. |
All variants are gated by useHiResBadgeVisibility, which reads profile_setting['ui.show_hi_res_badge'] (default true) and re-reads on the waveflow:hi-res-badge-visibility window event. Settings β Appearance ships HiResBadgeCard to flip the flag β when off, every mounted HiResBadge returns null in one render, including the player-bar text label. Per-profile so a kid's profile can hide the audiophile chrome while the audiophile profile keeps it.
Hovering (or keyboard-focusing) the footer opens AudioPipelinePopover β an audiophile-grade breakdown of what the engine is actually doing.
- Source β codec, sample rate, bit depth, bitrate, channel layout (
Mono/Stereo/3.0/4.0/5.0/5.1/6.1/7.1). - Processing β chips lighting up for every active stage. The two conversion chips inline the actual delta so they match the footer's arrow notation:
Rééchantillonnage 48 β 44.1 kHz,Downmix 5.1 β Stereo. The other chips stay as bare labels:DSD β PCM,EQ,ReplayGain,Normalize,Monomixdown,Speed β 1Γ. No chip β "Aucun traitement appliquΓ©". - Output β device sample rate + channel layout read from the live engine snapshot (
PlayerStateSnapshot.sample_rate/channels), not the track row, so resampling and downmix are reflected correctly.
The green Bit-perfect pill at the bottom appears only when no processing chip is active and the source rate matches the output rate. Any single chip lit (including EQ and Speed) suppresses it.
Hydration on open runs playerGetState / playerGetAudioSettings / playerGetEq in parallel so every read reflects the freshest engine state β the EQ may have been flipped from another popover seconds ago and we want truth, not stale React state. The popover unmounts on hover-leave so we never display stale data once it closes. 120 ms open / 200 ms close hover delays so brushing the footer doesn't flicker the popover open.
Action β key bindings live in src/lib/shortcuts.ts (12 actions, defaults like Space β play/pause, β/β β previous/next, M β mute, S β shuffle, R β repeat, L β toggle lyrics, Shift+L β like). useGlobalShortcuts is mounted once in AppLayout and attaches a single window.keydown listener that dispatches against PlayerContext. Listener skips when the focus target is INPUT / TEXTAREA / contenteditable so typing in a search box doesn't toggle shuffle.
User overrides are stored per-profile in profile_setting['ui.shortcuts'] as a JSON object containing only customised actions β defaults stay implicit, so future default tweaks land for any binding the user hasn't touched. Settings β Raccourcis clavier (ShortcutsCard) captures keys in capture-phase so the rebind UI doesn't fire the global handler. Conflicts auto-resolve by stealing the combo from whoever previously owned it. AboutView reads the same setting and re-renders on the waveflow:shortcuts-changed window event.
- Dark mode β animated radial transition via the View Transitions API. Falls back to an instant swap when unsupported.
prefers-reduced-motionrespected for the radial transition and for animated SVGs.- Single-click play β optional Settings toggle; the default is double-click to mirror Apple Music / Finder.
17 locales in src/i18n/locales/: fr (source of truth), en, es, de, it, nl, pt, pt-BR, ru, tr, id, ja, kr (registered as ko + kr alias), zh-CN, zh-TW, ar, hi. Auto-detected at first launch from the OS locale, switchable from Settings.
There is no per-key fallback, so every locale must include every key. index.ts sets document.documentElement.dir per language so Arabic renders RTL automatically.
Non-French locales were bulk-translated from fr.json through DeepL with explicit music-player context, then post-processed to keep brand tokens (WaveFlow, Last.fm, Deezer, ReplayGain, LRCLIB, BPM) verbatim and preserve i18next {{placeholder}} interpolation.
To add a language:
- Create
src/i18n/locales/xx.json(same structure asfr.json) - Import it in
src/i18n/index.tsand add toSUPPORTED_LANGUAGES - It appears in the Settings selector automatically
Per-profile isolated database (libraries, playlists, settings, play history); shared metadata cache across profiles (artwork, Deezer / Last.fm metadata, lyrics).
- The
profiletable lives inapp.dbalong withapp_setting['app.last_profile_id']. - Boot flow: if no profiles exist, create "Default"; otherwise activate
last_profile_id, falling back to the most-recently-used profile if it points to a deleted row. - Profile switch closes the current per-profile pool and opens the new one β UI reactively re-fetches via every
*ProviderwatchingactiveProfile.id.LibraryContextalso exposesloadedProfileId(the id itslibrariesarray was last fetched for) so consumers like the onboarding gate inAppLayoutwait for a fresh fetch instead of evaluating against the previous profile's data.refresh()snapshots the active profile id before itsawaitand drops late writes when the user has since switched.
ProfileSelectorModal hosts the lifecycle:
- Create β "+" tile in the select view β name + colour picker β backend
create_profilereserves the row, materialisesprofiles/<id>/, and runs the initial migration. The freshly-created profile is auto-activated. - Delete β Netflix-style "Manage" toggle (pencil β check) in the top-left corner reveals a red trash badge on every non-active profile; tapping it opens a destructive confirmation view. Backend
delete_profilerefuses the active profile and the last remaining profile. The guard is atomic: a single SQL statement (DELETE FROM profile WHERE id = ? AND (SELECT COUNT(*) FROM profile) > 1) couples the "must not be last" predicate with the mutation so two concurrent deletes can never empty the table. Disambiguation between "not found" and "last profile" is handled on the failure path. After the row is removed,profiles/<id>/is wiped from disk andapp.last_profile_idis cleared if it pointed to the deleted profile.
commands/profile_io.rs packages a profile into a single .waveflow (zip) file containing manifest.json + data.db + the per-profile artwork/ directory. Settings β Stockage exposes both buttons.
- Export: the active-profile path runs
PRAGMA wal_checkpoint(TRUNCATE)first so the bundled DB captures every committed page (otherwise a busy WAL would leave the archive holding a partial snapshot). The CPU-bound zip work runs ontokio::task::spawn_blocking. - Import: always allocates a fresh profile row β never overwrites β then extracts the archive under
profiles/<new_id>/. Failures roll the row back so a half-imported profile doesn't survive the error. Before the sqlx migrator runs,normalise_migration_checksumsrewrites_sqlx_migrations.checksumfor every version present in both the archive and the local migrator β older builds checked out migration files with CRLF endings (Windowscore.autocrlf=true+ no.gitattributeslock) so their stored SHA-384 differs from the same SQL re-hashed today, even though the DDL is identical. A.gitattributesat repo root now pins*.sql/*.rs/*.ts/ etc. to LF so future archives stay byte-stable. Once normalised, the new pool is opened once so any pending sqlx migrations replay before the user switches to it. An archive whose_sqlx_migrationslists a version unknown to the local migrator is rejected β that means the export came from a newer build. - Out of scope: the shared
app.db(Last.fm key, Discord opt-in,network.offline_mode) belongs to the install, not the profile. The sharedmetadata_artwork/cache (Deezer pictures, etc.) is re-fetchable so we skip it to keep archives small. - Manifest:
archive_version(currently1) gates compatibility β a future schema-incompatible bump refuses imports rather than silently corrupting the new profile.app_versionand the source profile name / id are recorded for diagnostics.
Opt-in scheduled mirror of the manual export so the user's playlists / likes / ratings / history survive a SQLite corruption or disk failure. Implementation in backup.rs:
- Config lives in
app_setting(install-wide, not per-profile):backup.enabled(bool, default OFF),backup.interval_days(1-90, default 7),backup.folder(string; empty = default<app_data>/waveflow/backups/),backup.retention(1-50, default 5 β per profile),backup.last_run_at(epoch ms). - Loop is a single tokio task started once at boot (
spawn_backup_loop). When disabled, parks on atokio::sync::Notify(zero cost) until the user toggles. When enabled, computes the next deadline aslast_run_at + interval_days * 86_400_000and usestokio::select!between a sleep and the sameNotifyso config changes wake it without waiting for the old sleep to expire. - Pass (
run_one_backup) iterates every row inprofile, calls the sharedprofile_io::write_archive(pub-crate-ified from the manual-export path so the two stay bit-compatible), and applies retention per profile (<sanitized-name>-*.waveflowsorted by mtime, oldest beyondretentiondeleted). The active profile gets aPRAGMA wal_checkpoint(TRUNCATE)first; inactive profiles are already cold on disk (the pool ran a checkpoint at switch / shutdown). - Failure isolation: per-profile errors are logged but don't abort the pass β one corrupt profile shouldn't block backups of the healthy ones.
- Commands in
commands/backup.rs:get_backup_config,set_backup_config(also signals the loop),run_backup_now. UI isBackupCardin Settings β Stockage right after the manual export/import.
SettingsView is split into seven Lokal-style horizontal tabs rendered as a proper ARIA role="tablist" at the top of the page (keyboard-navigable, aria-selected per panel):
| Tab | Houses |
|---|---|
library |
Library folders, scan-on-start, file watcher |
playback |
EQ, crossfade, ReplayGain, normalisation, WASAPI exclusive, mono |
integrations |
Last.fm, Discord RPC, Deezer enrichment, DLNA media server |
appearance |
Theme picker (14 presets) + player-bar layout |
data |
Profile export / import, auto-backup, statistics export, offline |
shortcuts |
Per-action keyboard rebinder (ShortcutsCard) |
diagnostics |
Log folder reveal, recent log tail, app info |
Only one panel mounts at a time, so heavy sub-views (EQ visualiser, backup card, shortcuts editor) don't run their effects until the user opens that tab.
THEME_PRESETS ships 14 presets split into two visual rows:
| Row | Presets |
|---|---|
| Light | Γmeraude Β· Midnight Β· Sunset Β· Lavender Β· Crimson Β· Ocean |
| Dark | Γmeraude Β· OLED Β· Midnight Β· Sunset Β· Lavender Β· Crimson Β· Ocean Β· Neon |
Each preset declares a 50β950 OKLCH accent palette + a mode (light / dark) + an ambient body color + optional surfaceDark / surfaceDarkElevated overrides. applyTheme writes --accent-50..950, --ambient-bg, --color-surface-dark, --color-surface-dark-elevated on <html>, and Tailwind v4's @theme inline block in app.css remaps every bg-emerald-* / text-emerald-* utility + the bg-surface-dark* utilities to those vars β so a swap re-tints the entire app without touching a single component.
The surfaceDark family is non-optional in practice for any themed dark preset: leaving it on the default #121212 produces a flat charcoal sidebar against a violet / amber / rose body (the bug fixed when these tokens went theme-aware). Each themed dark preset sets surfaceDark = ambient and surfaceDarkElevated β ambient + small lightening step so sidebar / right panels / player bar all carry the theme tint while elevated cards still read above the body.
A small inline script in index.html runs before React mounts to paint the right dark class + data-theme + --ambient-bg from the stored preset id, so a fresh boot doesn't flash white when the default theme is dark. The script keeps a LIGHT_IDS lookup mirroring themes.ts β both tables must stay in sync if a preset is added or removed.
Switching uses View Transitions API: a radial reveal from the click point on supported browsers, a plain crossfade on the rest. setThemeId wraps document.startViewTransition in try/catch because some WebKitGTK builds throw synchronously β the fallback calls setTheme(next) directly so the persisted id never desyncs from the applied palette. The persisted id lives in localStorage['waveflow.theme.id']; the legacy waveflow.theme.is_dark boolean from v1.x is migrated on first read (written under the new key + removed) so a downgrade-then-upgrade cycle can't silently overwrite a custom preset.
OnboardingModal walks new profiles through a Lokal-style multi-step wizard. Steps in order:
- welcome β branding + privacy pitch.
- language β picker over
SUPPORTED_LANGUAGES; persists immediately so the rest of the wizard renders in the chosen locale. - profile (conditional) β name the auto-created "Default" profile in place via
rename_profile. Safe against the active profile since onlyapp.dbis touched; the per-profile pool keeps its open handle. Skipping the rename (input unchanged) avoids the backend round-trip entirely. The step is omitted entirely when the active profile's name isn't the literal"Default"β i.e. profiles created through the New Profile modal already carry a user-supplied name, so the rename step would just ask the same question twice."Default"is the hardcoded auto-bootstrap name fromstate.rs::create_default_profile(not localised, so the comparison is reliable). - localOnly β explainer that the library never leaves the device unless the user opts into Last.fm / Discord later.
- folder β calls
pickFolderto select a music root and creates the first library entry. - lastfm β optional Last.fm API key + secret pairing (skippable). Status lives in
integration.rs. - scan β kicks off the initial scan and surfaces progress.
- done β success state with a "Open the app" button.
The modal is laid out as flex flex-col max-h-[calc(100vh-2rem)] with the progress bar pinned to the top (shrink-0), the step body in the middle (overflow-y-auto flex-1 min-h-0), and the action bar pinned to the bottom (shrink-0). Without those constraints the wizard's tallest steps (Last.fm with 4 inputs + button) push the header and footer off-screen on 1080p displays.
The decision is latched once per profile via profile_setting['onboarding.dismissed'], so the wizard never reappears after a "configure later" / completed run β even if the library stays empty.
The modal only opens when:
- the profile is fully resolved (no boot-time flicker),
- the
LibraryContexthas refetched for the new profile id (loadedProfileId === activeProfile.id), so a switch from a populated profile to a brand-new empty one is detected with the new profile's data instead of the previous closure, - the library is empty,
onboarding.dismissedisn't set for this profile.
Tauri updater plugin with a signed update flow. The update banner offers "Install now" without forcing a relaunch interruption. Wired in release builds only β in tauri dev the local source tree wouldn't have a signed manifest to fetch, so the plugin would just spam errors. See lib.rs for the #[cfg(not(debug_assertions))] gate.