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).
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. 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.
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), 280Γ380 withdecorations: false(we render our own top bar) andalwaysOnTop: true. Anchored 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. Hides the main window on open; the mini's Maximize button restores it and closes the mini. - 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, transparent, 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. main.tsx runs the takeover after the first React frame: show the main window first, then close the splash so the desktop is never visible between the two. The mini-player webview branches out via ?mini=1 and skips the dance.
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, hidden entirely when available_wrapped_years returns an empty list.
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, Fullscreen | Always visible. Spotify-style right cluster (mini-player + fullscreen) sits after volume |
| Overflow | Playback speed (slider + presets), A-B loop, Sleep timer (panel) | MoreActionsMenu β "β―" popover; the trigger itself is hidden when nothing inside is left |
| Pinnable | A-B loop, Sleep timer (promote to primary) | Toggle in Settings β Lecture (see below) |
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. If both placements make sense, expose a pin toggle. The "β―" trigger auto-hides when its menu would be empty.
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.
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.
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.
OnboardingModal prompts new profiles to point at a music folder. The decision is latched once per profile via profile_setting['onboarding.dismissed'], so the modal never reappears after a "configure later" choice β even if the library stays empty.
The modal only appears when the profile is fully resolved AND the library is empty AND the flag isn't set, preventing flashes during boot transitions.
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.