Skip to content

Latest commit

Β 

History

History
195 lines (124 loc) Β· 25.1 KB

File metadata and controls

195 lines (124 loc) Β· 25.1 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).

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

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), 280Γ—380 with decorations: false (we render our own top bar) and alwaysOnTop: 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.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, 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.

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. The HomeView entry point is a gradient banner above the Mood Radio grid, hidden entirely when available_wrapped_years returns an empty list.

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

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.

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.

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.

Onboarding

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.

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.