Skip to content

Latest commit

Β 

History

History
326 lines (207 loc) Β· 47.3 KB

File metadata and controls

326 lines (207 loc) Β· 47.3 KB

UI & UX

The UI is React 19 + Tailwind CSS 4. The provider tree is mounted in App.tsx; the layout shell is in AppLayout.tsx.

Layout

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).

View loading & code-splitting

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.

Panels

  • 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 to QueuePanel. 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.

Immersive Now Playing

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.

Mini-player

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 (label mini), default 280Γ—380 with decorations: false (we render our own top bar) and alwaysOnTop: 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 debounced onMoved / onResized listeners in MiniPlayer.tsx (300 ms after the last gesture so SQLite isn't hammered at 60 Hz while dragging). On open, miniPlayer.ts::openMiniPlayer restores 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.tsx on ?mini=1 so the mini boots into a stripped-down provider tree (Theme + Profile + Player only β€” no Library / Playlist since the widget never browses).
  • Cover-derived background β€” lib/dominantColor.ts draws 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-region on the central dot strip, plus an explicit getCurrentWindow().startDragging() onMouseDown as a belt-and-suspenders fallback for the Windows hit-test races. Requires core:window:allow-start-dragging in the capability (not in core: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 + local dragMs pattern as the main ProgressBar. Thumb + timestamps fade in on hover so the idle widget stays minimal.
  • Capabilities β€” the mini-player's window label is added to capabilities/default.json so it inherits every command the main window has access to (no duplicated capability file, no per-window permission pruning).

Splash screen

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+).

System tray

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.

Statistics view

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. Reuses stats_listening_by_day with range="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 via spawn_blocking so we don't depend on tauri-plugin-fs just to round-trip a string. Frontend trigger is the Download button next to the range selector in the header.

WaveFlow Wrapped

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 from commands/stats.rs so 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-side Local::now().year() so the frontend doesn't depend on the JS Date for 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.

Home banner visibility

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 whenever available_wrapped_years returns 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.

Shareable PNG

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.

Now Playing share card

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.

Width & containers

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.

Performance

  • Virtual scroll β€” @tanstack/react-virtual on every long list (tracks, queue, playlist contents, statistics rows). Tables share the page-level scroller via usePageScroll() and compute scrollMargin from 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) for convertFileSrc results so the same artwork URL isn't recomputed on every render.
  • Thumbnails β€” 1Γ— and 2Γ— covers generated by thumbnails.rs with fast_image_resize (SIMD AVX/SSE/NEON depending on host) and served via the asset protocol.

Player-bar layout

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.

Pin toggles

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.

Audio-quality footer + pipeline popover

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.

Hi-Res / DSD badge

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.

Sections displayed

  • 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, Mono mixdown, 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.

Bit-perfect conditions

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.

State refresh

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.

Keyboard shortcuts

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.

Theming & motion

  • Dark mode β€” animated radial transition via the View Transitions API. Falls back to an instant swap when unsupported.
  • prefers-reduced-motion respected for the radial transition and for animated SVGs.
  • Single-click play β€” optional Settings toggle; the default is double-click to mirror Apple Music / Finder.

i18n

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:

  1. Create src/i18n/locales/xx.json (same structure as fr.json)
  2. Import it in src/i18n/index.ts and add to SUPPORTED_LANGUAGES
  3. It appears in the Settings selector automatically

Profiles

Per-profile isolated database (libraries, playlists, settings, play history); shared metadata cache across profiles (artwork, Deezer / Last.fm metadata, lyrics).

  • The profile table lives in app.db along with app_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 *Provider watching activeProfile.id. LibraryContext also exposes loadedProfileId (the id its libraries array was last fetched for) so consumers like the onboarding gate in AppLayout wait for a fresh fetch instead of evaluating against the previous profile's data. refresh() snapshots the active profile id before its await and drops late writes when the user has since switched.

Create / delete

ProfileSelectorModal hosts the lifecycle:

  • Create β†’ "+" tile in the select view β†’ name + colour picker β†’ backend create_profile reserves the row, materialises profiles/<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_profile refuses 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 and app.last_profile_id is cleared if it pointed to the deleted profile.

Export / import (.waveflow archive)

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 on tokio::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_checksums rewrites _sqlx_migrations.checksum for every version present in both the archive and the local migrator β€” older builds checked out migration files with CRLF endings (Windows core.autocrlf=true + no .gitattributes lock) so their stored SHA-384 differs from the same SQL re-hashed today, even though the DDL is identical. A .gitattributes at 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_migrations lists 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 shared metadata_artwork/ cache (Deezer pictures, etc.) is re-fetchable so we skip it to keep archives small.
  • Manifest: archive_version (currently 1) gates compatibility β€” a future schema-incompatible bump refuses imports rather than silently corrupting the new profile. app_version and the source profile name / id are recorded for diagnostics.

Auto-backup

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 a tokio::sync::Notify (zero cost) until the user toggles. When enabled, computes the next deadline as last_run_at + interval_days * 86_400_000 and uses tokio::select! between a sleep and the same Notify so config changes wake it without waiting for the old sleep to expire.
  • Pass (run_one_backup) iterates every row in profile, calls the shared profile_io::write_archive (pub-crate-ified from the manual-export path so the two stay bit-compatible), and applies retention per profile (<sanitized-name>-*.waveflow sorted by mtime, oldest beyond retention deleted). The active profile gets a PRAGMA 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 is BackupCard in Settings β†’ Stockage right after the manual export/import.

Settings categories

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 system

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.

Onboarding

OnboardingModal walks new profiles through a Lokal-style multi-step wizard. Steps in order:

  1. welcome β€” branding + privacy pitch.
  2. language β€” picker over SUPPORTED_LANGUAGES; persists immediately so the rest of the wizard renders in the chosen locale.
  3. profile (conditional) β€” name the auto-created "Default" profile in place via rename_profile. Safe against the active profile since only app.db is 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 from state.rs::create_default_profile (not localised, so the comparison is reliable).
  4. localOnly β€” explainer that the library never leaves the device unless the user opts into Last.fm / Discord later.
  5. folder β€” calls pickFolder to select a music root and creates the first library entry.
  6. lastfm β€” optional Last.fm API key + secret pairing (skippable). Status lives in integration.rs.
  7. scan β€” kicks off the initial scan and surfaces progress.
  8. 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 LibraryContext has 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.dismissed isn't set for this profile.

Auto-updater

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.