diff --git a/CLAUDE.md b/CLAUDE.md index 8a9e960..7b70f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -WaveFlow is a local music player desktop app built with **Tauri 2 + React 19 + TypeScript + Vite**. It uses a Spotify/Apple Music-inspired UI for browsing and playing local audio files. The project uses **bun** as the package manager. +WaveFlow is a local music player desktop app built with **Tauri 2 + React 19 + TypeScript + Vite** and a **bun** toolchain. Spotify / Apple Music-inspired UI on top of a Rust audio engine. -For per-feature deep dives (algorithms, schema, flow diagrams) read the relevant page under [`docs/`](docs/README.md) — that's the source of truth when this file's overview isn't enough. This `CLAUDE.md` only covers the cross-cutting patterns Claude needs in every conversation. +This file only covers the cross-cutting rules Claude needs in every conversation. **For per-feature deep dives (algorithms, schema, flow diagrams) read the relevant page under [`docs/features/`](docs/README.md)** — that's the source of truth when the overview here isn't enough. ## Development Commands @@ -14,23 +14,17 @@ For per-feature deep dives (algorithms, schema, flow diagrams) read the relevant # Install dependencies bun install -# Run the Tauri desktop app in development mode (starts Vite dev server + Rust backend) +# Run the Tauri desktop app in development mode (Vite + Rust backend) bun run tauri dev # Build the production desktop app bun run tauri build -# Run only the Vite frontend dev server (no Tauri shell) -bun run dev - -# TypeScript type check -bun run typecheck - -# Lint -bun run lint - -# TypeScript check + Vite production build (frontend only) -bun run build +# Frontend only +bun run dev # Vite dev server (no Tauri shell) +bun run typecheck # tsc --noEmit +bun run lint # eslint +bun run build # tsc + Vite prod build # Rust backend cargo check --manifest-path src-tauri/Cargo.toml --all-targets @@ -41,103 +35,87 @@ cargo test --manifest-path src-tauri/Cargo.toml ### Frontend (`src/`) -React 19 + TypeScript. Entry point: `src/main.tsx` → `src/App.tsx`. Vite dev server on port 1420. +React 19 + TypeScript. Entry: `src/main.tsx` → `src/App.tsx`. Vite dev server on port 1420. -- **Contexts**: `ThemeContext`, `PlayerContext`, `LibraryContext`, `PlaylistContext`, `ProfileContext` — mounted in `App.tsx` as a provider tree. `PageScrollContext` is mounted lower (inside `AppLayout`) and exposes the main scrollable area's ref to virtualized tables so the page drives a single scrollbar. -- **Hooks**: `useTheme`, `usePlayer`, `useLibrary`, `usePlaylist`, `useProfile`, `usePageScroll` — each wraps its context. -- **Tauri wrappers** (`src/lib/tauri/`): typed `invoke()` wrappers for every backend command (`track.ts`, `browse.ts`, `player.ts`, `playlist.ts`, `library.ts`, `detail.ts`, `integration.ts`, `lyrics.ts`, `stats.ts`, `artwork.ts`, `analysis.ts`, `dialog.ts`, `deezer.ts`, `profile.ts`). All commands are camelCase on the frontend, snake_case on the backend. -- **Views**: `HomeView`, `LibraryView`, `PlaylistView`, `AlbumDetailView`, `ArtistDetailView`, `LikedView`, `RecentView`, `StatisticsView`, `SettingsView`, etc. -- **Common components**: `ArtistLink` (per-name clickable multi-artist renderer), `Artwork` (file-scoped asset protocol resolver), playlist visuals. -- **Layout**: Apple Music-style sidebar (Ma musique sub-navs + Playlists section), TopBar with search, PlayerBar at bottom, two right-edge panels (`QueuePanel` + `NowPlayingPanel`) mutually exclusive via `PlayerContext`. +- **Contexts** (mounted as provider tree in `App.tsx`): `ThemeContext`, `PlayerContext`, `LibraryContext`, `PlaylistContext`, `ProfileContext`. `PageScrollContext` is mounted lower (in `AppLayout`) and exposes the main scrollable area to virtualized tables — single page-driven scrollbar. +- **Hooks** wrapping each context: `useTheme`, `usePlayer`, `useLibrary`, `usePlaylist`, `useProfile`, `usePageScroll`. +- **Tauri wrappers** (`src/lib/tauri/`): typed `invoke()` per backend command. Frontend uses camelCase, backend uses snake_case. +- **Views**: `HomeView`, `LibraryView`, `PlaylistView`, `AlbumDetailView`, `ArtistDetailView`, `LikedView`, `HistoryView`, `StatisticsView`, `WrappedView`, `SettingsView`, etc. +- **Layout**: Apple-Music-style sidebar (Ma musique + Playlists), TopBar with search, PlayerBar at bottom, right-edge panels (`NowPlayingPanel` / `QueuePanel` / `LyricsPanel`) mutex'd via `PlayerContext`. + +A second `WebviewWindow` (label `mini`, `?mini=1` route) ships the always-on-top mini-player — see [`docs/features/ui.md`](docs/features/ui.md#mini-player). ### Backend (`src-tauri/`) -Rust/Tauri 2. Entry point: `src-tauri/src/main.rs` → `lib.rs`. - -- **Commands** (`src-tauri/src/commands/`): organized by domain — `library.rs`, `playlist.rs` (CRUD + M3U import/export), `smart_playlists.rs` (Daily Mix regen entry point), `track.rs`, `browse.rs`, `player.rs` (playback + output device list/select), `scan.rs`, `profile.rs`, `analysis.rs` (peak/loudness/ReplayGain/BPM), `deezer.rs` (metadata enrichment + batch fill-in for missing artist pictures and album covers), `similar.rs` (similar-artist discovery, Last.fm primary + Deezer `/related` fallback, cached in `app.lastfm_similar`), `lyrics.rs` (LRCLIB + embedded fallback + .lrc import + library-wide prefetch), `stats.rs` (listening analytics from `play_event`), `integration.rs` (Last.fm API key storage), `maintenance.rs`, `app_info.rs`. All registered in `lib.rs` via `generate_handler![]`. -- **External API clients** (crate-root modules): `deezer.rs` (public Deezer API, no auth) and `lastfm.rs` (Last.fm `artist.getInfo`, requires user-provided API key). Both use `reqwest` with `rustls-tls`. -- **DLNA / UPnP MediaServer** ([`src-tauri/src/dlna/`](src-tauri/src/dlna/)): worker thread owning a tokio runtime + an axum HTTP server + an SSDP announcer/responder on `239.255.255.250:1900`. Exposes the active profile as `urn:schemas-upnp-org:device:MediaServer:1` so Yamaha / Sonos / Kodi / VLC discover and stream the library over the LAN. Object IDs are string prefixes (`0/artists/`, `0/albums/`, `0/track/`); ContentDirectory `Browse` paginates via `LIMIT/OFFSET` capped at 500. `/stream/` serves audio with HTTP `Range` (`206 Partial Content` + DLNA hints). Opt-in flag `app_setting['dlna.enabled']`, default OFF. Persisted config (`server_name`, `port`) lives in `app_setting` because the server is process-wide, not per-profile. See [`docs/features/dlna.md`](docs/features/dlna.md). -- **Discord Rich Presence** (`src-tauri/src/discord_presence.rs`): named-pipe IPC bridge to the local Discord client via the `discord-rich-presence` crate. Same dedicated-thread + crossbeam-channel pattern as `media_controls.rs`. Spotify-style "Listening to WaveFlow" card (title / artist / album + cover from Deezer + progress bar). Opt-in flag in `app_setting['integrations.discord_rpc']`, **default ON**. See [`docs/features/integrations.md`](docs/features/integrations.md#discord-rich-presence). -- **OS media controls** (`src-tauri/src/media_controls.rs`): souvlaki bridge wired to SMTC / MPRIS / MediaRemote. Initialized after the main window exists (needs HWND on Windows). Now Playing artwork is served to SMTC over a tiny localhost HTTP shim because Windows expects a URL, not a file path. -- **Audio engine** (`src-tauri/src/audio/`): 3-thread lock-free architecture: - - `engine.rs` — `AudioCmd` enum, `AudioEngine` handle - - `decoder.rs` — symphonia decode loop, rubato resampling - - `output.rs` — cpal callback on dedicated thread, SPSC ring buffer (rtrb), output-device enumeration (ALSA hints on Linux, `cpal` elsewhere) - - `state.rs` — `SharedPlayback` with atomics (no locks in hot path) - - `analytics.rs` — tokio task for play_event writes + auto-advance - - `crossfade.rs` — dual-decoder mix with equal-power gain curves over the user-set window. `ActiveStream` carries a `StreamBackend` enum (`Symphonia` / `Dsd`) so the seek + reset paths are uniform across formats. - - `dsd/` — DSF + DFF parsers, in-house DSD-to-PCM converter (256-tap Blackman-Harris FIR, decimation 64 → DSD64 lands at 44.1 kHz), and a metadata reader (DSF carries an ID3v2 blob in its footer; DFF uses native DIIN/COMT chunks). Symphonia 0.5 doesn't decode DSD natively; this is the entire DSD playback path. -- **Queue** (`src-tauri/src/queue.rs`): persistent queue with fill, advance, shuffle (Fisher-Yates), restore. -- **Smart playlists** (`src-tauri/src/smart_playlists/`): auto-generated Daily Mix family. `generator.rs` clusters top artists by tempo (BPM from `track_analysis`) and materializes `is_smart = 1` rows in the regular `playlist` table; `cover.rs` renders a composite from up to 3 cached Deezer artist pictures into the shared `metadata_artwork/.jpg` cache. Idempotent — re-running rewrites the same slot via `LIKE '%"slot":N%'` on `smart_rules`. See [`docs/features/smart-playlists.md`](docs/features/smart-playlists.md). -- **Database**: per-profile SQLite via sqlx + a global `app.db` for profile list and app-wide settings (`app_setting` table — including the Last.fm API key). Migrations under `migrations/profile/` are append-only and applied at boot. FTS5 contentless for search with auto-sync triggers using the `'delete'` command. **Never edit a migration file once it has been merged to `main`** — sqlx records a SHA-384 checksum of each migration in `_sqlx_migrations.checksum` at apply time, and a content change makes every existing install crash at boot with `"migration was previously applied but has been modified"` (no auto-recovery, the user has to wipe their data dir). For any schema evolution, **create a new dated migration file** (`YYYYMMDDhhmmss_.sql`) that does the `ALTER TABLE` / `CREATE INDEX` / data backfill. Same rule applies to `migrations/app/`. - -### Key Patterns - -- **Tauri commands**: `#[tauri::command]` in `commands/*.rs`, registered in `lib.rs` `generate_handler![]`, called from React with `invoke("command_name", { args })`. -- **Profile-scoped pool**: `state.require_profile_pool().await?` — every command that touches user data goes through this. -- **Persistence**: settings stored in `profile_setting` table (key-value with typed values). Pattern: `INSERT ... ON CONFLICT DO UPDATE`. -- **Events**: backend emits Tauri events (`player:state`, `player:position`, `player:track-changed`, `player:queue-changed`, `player:error`). Frontend listens via `listen()` from `@tauri-apps/api/event`. -- **Audio callback constraints**: the cpal callback MUST NOT allocate, lock, or log. Only `rtrb::Consumer` + `Atomic*` reads. -- **Virtual scroll**: TrackTable uses `@tanstack/react-virtual` for 6000+ track performance. -- **Multi-artist**: the scanner splits `"A, B"` on `", " / "; "` into individual `artist` rows linked via the `track_artist` many-to-many table. Queries rebuild the display string via `GROUP_CONCAT` over `track_artist` ordered by `position`. `ArtistLink` accepts parallel `artist_name` + `artist_ids` strings so every contributor is individually clickable. -- **Album grouping (Album Artist + Compilation)**: [`scan.rs::upsert_album`](src-tauri/src/commands/scan.rs) keys albums on `(canonical_title, artist_id)` where `artist_id` resolves to the **album artist**, not the first track's primary artist. The scanner reads the Album Artist tag (`TPE2` / `aART` / `ALBUMARTIST` / `Album Artist`) via lofty's `ItemKey::AlbumArtist` and the compilation flag (`TCMP` / `cpil` / `COMPILATION`) via `ItemKey::FlagCompilation`. Resolution order in `resolve_album_artist`: (1) explicit Album Artist tag, (2) `is_compilation=1` → synthetic `"Various Artists"` artist row, (3) fallback to the track's primary artist (preserves v1.0 behaviour for untouched files). The `album.album_artist` TEXT column stores the raw source casing for display; `album.is_compilation` is sticky — once any track in the album set declares the flag, the row keeps it even if a later scan drops it. Schema bump lives in [`20260514180000_album_artist.sql`](src-tauri/migrations/profile/20260514180000_album_artist.sql) (two `ALTER TABLE ADD COLUMN`s, no rebuild). [`list_albums`](src-tauri/src/commands/browse.rs) + [`get_album_detail`](src-tauri/src/commands/browse.rs) `COALESCE(ar.name, al.album_artist)` so a Various-Artists row with no resolved artist row still renders a sensible label. [`edit.rs::update_track_tags`](src-tauri/src/commands/edit.rs) reads the OLD album's `album_artist` + `is_compilation` before re-running `upsert_album` so renaming an album doesn't fall back to the primary-artist path and re-split. After every scan, [`merge_implicit_compilations`](src-tauri/src/commands/scan.rs) sweeps the album table for canonical titles that exist as ≥ 3 distinct-artist rows with `album_artist IS NULL AND is_compilation = 0` (typical Soothing-Breeze-style lofi packs without any tagging) and merges them under the "Various Artists" sentinel — survivor keeps the lowest `id`, sibling rows have their tracks re-pointed before deletion. The `upsert_album` lookup additionally falls through to any existing `is_compilation = 1` row matching the canonical title when the incoming track carries no Album Artist tag, so future rescans of an already-merged compilation never re-fragment. -- **Single-instance lock**: [`tauri-plugin-single-instance`](https://crates.io/crates/tauri-plugin-single-instance) is wired as the **first** plugin in [`lib.rs`](src-tauri/src/lib.rs) so a duplicate launch exits cleanly before any heavy init (pool open, audio engine, tray, watchers) runs in the second process. The handler shows / unminimizes / focuses the existing main window, matching the behaviour of Spotify and most desktop music players. No new IPC command — invisible to the frontend. -- **Folder cover fallback**: when a track has no embedded picture, [`scan.rs::extract_folder_cover`](src-tauri/src/commands/scan.rs) probes the parent directory for `cover|folder|front|albumart|album|artwork.{jpg,jpeg,png,webp,bmp,gif,tiff}` (stem priority, not alphabetical), hashes the bytes with blake3 and stores them in the same `artwork/` dir with `source = 'folder'`. `upsert_artwork` takes the source label as a parameter so embedded / folder / Deezer / user provenance stays queryable for future cleanup jobs. -- **Metadata cache**: Deezer (pictures, fans) and Last.fm (bios) results are cached in the `deezer_artist` / `deezer_album` tables stored in `app.db` (shared across profiles) with a 30-day `expires_at` TTL. Cache-first flow in `enrich_artist_deezer` — zero network if the row is fresh. Failures are non-fatal (empty enrichment returned, UI shows local data). The Last.fm API key is read from `app_setting` via `integration::read_lastfm_api_key` and is optional: without it, bios are skipped silently. -- **Remote artwork on disk**: Deezer pictures/covers are downloaded into the shared `/metadata_artwork/.jpg` cache by `metadata_artwork::download_and_cache`. The blake3 hash is persisted in `deezer_artist.picture_hash` / `deezer_album.cover_hash` (Deezer always serves JPEG, hence the hardcoded extension). Enrichment commands and `list_artists` / `get_artist_detail` / `stats_top_artists` return both `picture_url` (remote fallback) and `picture_path` (absolute local path); the frontend helper `lib/tauri/artwork.ts::resolveRemoteImage` prefers the local file via `convertFileSrc` so artist imagery renders offline. The `metadata_artwork/**` scope must stay listed in `tauri.conf.json` `assetProtocol`. -- **Output device persistence**: the chosen device's `name` is stored in `profile_setting['audio.output_device']`. `lib.rs` reads it during `setup` and forwards it to the audio engine, so playback resumes on the user's preferred sink without waiting for the frontend to settle. -- **WASAPI Exclusive Mode (Windows opt-in)**: [`audio/wasapi_exclusive.rs`](src-tauri/src/audio/wasapi_exclusive.rs) is a parallel output backend (Windows-only via `#[cfg(target_os = "windows")]`) that opens the device in **event-driven exclusive mode** at the device's mix-format sample rate — bypasses the Windows audio engine entirely so no system mixer / no per-app volume DSP / no automatic resampling sits between us and the DAC. Engaged via `profile_setting['audio.wasapi_exclusive']` (default OFF), settings card auto-hides on Linux/macOS via UA sniff. [`output::spawn_output_with_mode`](src-tauri/src/audio/output.rs) is the dispatch — when exclusive=true on Windows it tries the wasapi backend first and falls back transparently to cpal shared on any init failure (device busy with another exclusive app, no float-32 support, COM apartment conflict). The exclusive thread drains the **same `rtrb::Consumer`** as the cpal callback would, applying the same `paused_output` / `drain_silent` / volume / normalize / mono semantics so the user-facing behaviour is identical regardless of backend. Engine tracks the mode in an `AtomicBool` so `set_output_device` preserves the user's choice across device hot-swaps. Dependency: `wasapi` 0.17 + slim `windows-rs` feature slice (`Win32_Foundation`, `Win32_System_Com`, `Win32_System_Threading`) target-gated — adds ~5-10 MB to the Windows NSIS/MSI binary, zero impact on Linux/macOS bundles. Phase 1 scope = "bypass the OS mixer at the device rate"; per-track rate switching (true bit-perfect from source) is a future phase that needs WASAPI client reinitialisation on every rate change. -- **Tag editor**: [`commands/edit.rs::update_track_tags`](src-tauri/src/commands/edit.rs) writes user-edited tags back to the audio file via lofty (`save_to_path` is atomic on POSIX, requires exclusive handle on Windows — we pause playback first when the engine reports the edited track as `current_track_id`) AND mirrors the change into the database in a single transaction. Reuses the scanner's `upsert_artist` / `upsert_album` / `upsert_genre` helpers so the multi-artist split (`"A, B"` → two `track_artist` rows) and FTS5 sync (via existing triggers on `track`, `album.title`, `artist.name`) work the same as a fresh scan. Year writes via `ItemKey::Year` because lofty 0.24 doesn't expose `set_year` on Accessor. After save, emits `track:updated` (typed payload = track id), plus `library:rescanned` + `player:queue-changed` so existing listeners (LibraryContext, QueuePanel, PlayerBar) refresh without new wiring. UI: edit-mode toggle inside [`TrackPropertiesModal`](src/components/common/TrackPropertiesModal.tsx) — Pencil button flips the metadata fields to inputs, Save / Cancel replace the footer. -- **6-band peaking equalizer**: [`audio/eq.rs`](src-tauri/src/audio/eq.rs) — six biquad peaking filters (RBJ cookbook, Q≈0.707) at fixed frequencies matching Spotify (60 / 150 / 400 / 1000 / 2400 / 15000 Hz), ±12 dB per band. Applied in the decoder thread next to ReplayGain (NOT in the cpal callback, which must stay alloc-free). Bands are atomic i16 (tenths of dB) so the UI's drag-the-curve interaction writes through `player_set_eq_band` without blocking; coefficients are recomputed lazily in the audio thread when the dirty flag is observed OR when the sample rate changes between tracks. Twenty built-in presets shipped server-side (`PRESETS`); the frontend identifies the active one via exact-gain match and falls back to "Custom" otherwise. Persisted in `profile_setting['audio.eq_enabled']` + `profile_setting['audio.eq_bands']` (JSON array). Bypass is the master switch — when off, `EqProcessor::process` returns after a single atomic load. -- **Radio (Spotify-style auto-queue)**: [`commands/radio.rs::start_radio`](src-tauri/src/commands/radio.rs) builds a ~40-track queue from a seed: the seed itself first, up to 6 other tracks by the seed's primary artist, then tracks by similar artists pulled from the cached `app.lastfm_similar` payload (populated on demand by `get_similar_artists`). When the seed has a stored `track_analysis.bpm`, candidates are soft-filtered to a ±18 BPM window — the constraint is dropped automatically when fewer than 12 tracks survive it, so eclectic libraries don't end up with 5-track radios. Shuffle is a deterministic xorshift64 keyed on the seed track ID so re-launching the same radio twice gives a stable order. Frontend: entry in [`TrackContextMenu`](src/components/common/TrackContextMenu.tsx) + a "Démarrer la radio" button at the top of [`NowPlayingPanel`](src/components/layout/NowPlayingPanel.tsx). Both call `playerPlayTracks("radio", null, ids, 0)` so play_event rows get tagged for stats. -- **Mood radio (focus / chill / workout / party / sleep)**: [`commands/mood_radio.rs::start_mood_radio`](src-tauri/src/commands/mood_radio.rs) is a seedless variant — picks ~40 tracks matching one of five hard-coded BPM + LUFS presets (Focus = 60-110 BPM ≤ −14 LUFS, Sleep = ≤75 BPM ≤ −18 LUFS, etc.). Requires `track_analysis.bpm`; tracks without analysis are excluded. `RANDOM()` order each click + per-artist cap of 4 so a heavy listener's top act doesn't dominate. Reuses `source_type = 'radio'` to keep the schema CHECK constraint untouched. Companion `mood_radio_counts` returns the qualifying-track count per mood so the [`MoodRadioGrid`](src/components/views/home/MoodRadioGrid.tsx) on HomeView disables empty tiles instead of showing a row of dead buttons. Hidden entirely when no mood matches (= library has no analysed tracks yet). -- **Karaoke lyrics fullscreen**: [`FullscreenLyrics`](src/components/player/FullscreenLyrics.tsx) is mounted as a sibling overlay from [`LyricsPanel`](src/components/layout/LyricsPanel.tsx) — opened via the maximize button in the panel header. The side panel keeps the fetched payload + active-line state and passes them down as props so the overlay never re-fetches. Background = blurred copy of the current track artwork; foreground centers the active line and fades neighbours by distance (Apple-Music-style). Click any line to seek; Escape closes. -- **Folder removal + drag-and-drop import**: [`commands/library.rs::remove_folder_from_library`](src-tauri/src/commands/library.rs) detaches the watcher, deletes every track that lived under the folder, then drops the `library_folder` row in a single transaction (the schema's `ON DELETE SET NULL` would otherwise leave orphan tracks). UI is a per-folder trash button in the Bibliothèque → Dossiers tab with a confirm-on-second-click pattern. Drag-and-drop ships via [`hooks/useDragDropImport.ts`](src/hooks/useDragDropImport.ts) → [`commands/library.rs::import_paths`](src-tauri/src/commands/library.rs), which accepts a mix of folders + audio files, dedupes (files contribute their parent dir), `INSERT OR IGNORE`s into `library_folder` (the UNIQUE constraint absorbs duplicates) and runs `scan_folder_inner` per folder. AppLayout renders an emerald drop overlay with `pointer-events: none` so the drop still hits Tauri's native handler. -- **Duplicate detection**: [`commands/duplicates.rs`](src-tauri/src/commands/duplicates.rs) groups every available track by `file_hash` (BLAKE3) so byte-identical copies in different folders fall into the same bucket regardless of metadata. Re-encodes of the same source won't match — that's a fingerprinting problem and out of scope for the MVP. `delete_tracks(ids)` cascades through `track_artist`, `track_genre`, `playlist_track`, `play_event` via the schema's ON DELETE constraints; the audio files on disk are NOT touched. UI is [`DuplicatesModal`](src/components/common/DuplicatesModal.tsx) launched from Settings → Stockage with a radio-per-group "keep" selector (defaults to oldest by `added_at ASC`). -- **Mini-player (Spotify-style)**: second `WebviewWindow` (label `mini`, 280×380, `decorations: false`, `alwaysOnTop: true`) anchored bottom-right of the primary monitor with a 24 px edge margin. Loads the same Vite bundle with `?mini=1`; [`main.tsx`](src/main.tsx) branches into [`MiniPlayerApp`](src/MiniPlayerApp.tsx) which boots a stripped-down provider tree (Theme + Profile + Player only — no Library / Playlist). Background is a 3-stop gradient sampled from the cover's dominant colour via [`lib/dominantColor.ts`](src/lib/dominantColor.ts) (Canvas API, every-4th-pixel sampling, skips near-monochrome runs). Controls (shuffle / prev / play / next / repeat) fade in over the cover on hover; bottom seek bar is interactive (same pointer-capture pattern as the main `ProgressBar`). Drag region is the central dot strip — `data-tauri-drag-region` plus an explicit `getCurrentWindow().startDragging()` `onMouseDown` because Tauri 2's drag-region attribute hit-test is flaky on Windows AND requires `core:window:allow-start-dragging` in the capability (not in `core:default`). Pin button toggles `setAlwaysOnTop` at runtime; close hides the mini and re-shows the main window. UI: picture-in-picture button in the PlayerBar between Lyrics and Queue. -- **TXXX:UNSYNCEDLYRICS fallback**: [`commands/lyrics.rs::read_id3v2_txxx_lyrics`](src-tauri/src/commands/lyrics.rs) re-opens MP3s as `MpegFile`, downcasts to `Id3v2Tag`, and scans common lyric aliases (`UNSYNCEDLYRICS`, `UNSYNCED LYRICS`, `LYRICS_UNSYNCED`, `LYRICS`) when the standard `USLT`/`ItemKey::UnsyncLyrics` path comes up empty. Required because lofty's generic `Tag` interface drops unmapped TXXX user-defined frames, and many K-Pop / J-Pop rips tagged with Mp3tag / foobar2000 stash lyrics there instead of the standard USLT frame. -- **Custom smart playlists (recursive boolean rule tree)**: [`smart_playlists::custom`](src-tauri/src/smart_playlists/custom.rs) — `CustomRules` wraps a `RuleNode` tree (`All`/`Any`/`Not`/`Leaf`) so the editor can express arbitrary boolean expressions like `(artist contains "Daft Punk" OR artist contains "Justice") AND year ≥ 2000 AND NOT liked`. `build_node_sql` walks the tree and emits a single WHERE clause; every join-needing predicate (`artist_contains`, `album_contains`, `genre_is`, `bpm_*`, `liked`) goes through an `EXISTS` subquery so the tree can nest arbitrarily without DISTINCT or Cartesian explosions. Empty `All` → `1=1`, empty `Any` → `0=1` (degenerate but well-defined for blank-slate editor state). **v1 → v2 auto-migration**: a custom `Deserialize` impl detects the legacy flat shape (`title_contains: …, year_min: …, genre_ids: [1,2,3], …`) and folds it into an `All` root with each multi-value selector wrapped in `Any`. Migration is read-only — old JSON in `playlist.smart_rules` stays untouched until the next save, so a downgrade is safe. Five commands unchanged: `create/update/regenerate/get_rules/preview`. UI is [`RuleTreeEditor`](src/components/common/RuleTreeEditor.tsx) — a recursive React component that mirrors the data shape, with tinted group cards (AND = emerald, OR = violet, NOT = red) and an "Add condition / Add group / Add NOT" footer per group. Plugged into [`SmartPlaylistEditorModal`](src/components/common/SmartPlaylistEditorModal.tsx) which keeps the sort + limit fields outside the tree. -- **A-B repeat**: `SharedPlayback::loop_a_ms` + `loop_b_ms` (atomic u64s, both 0 = disarmed) drive a Musicolet-style intra-track loop. The decoder checks `ab_loop_armed()` once per packet at the top of [`audio/decoder.rs::play_track`](src-tauri/src/audio/decoder.rs) and seeks back to A whenever the playhead crosses B; the check is skipped during a crossfade because the loop is a single-track concern. The loop auto-clears on every `LoadAndPlay` so the new track doesn't inherit the previous track's endpoints. Backend commands `player_set_ab_loop` / `player_clear_ab_loop` / `player_get_ab_loop` emit `player:ab-loop` after every change so the UI button stays in sync across views. UI is [`AbLoopButton`](src/components/player/AbLoopButton.tsx) in the PlayerBar — three-state click cycle (idle → A captured → A+B armed → clear), with an "A" / "AB" badge and amber/emerald tone matching the Lyrics + Sleep timer accents. -- **Lyrics editor (plain + Musicolet-style synced)**: [`save_lyrics`](src-tauri/src/commands/lyrics.rs) writes user-edited lyrics into the `app.lyrics` cache (source `manual`) and, when the `write_to_file` flag is set, also pushes them into the file's `USLT` (ID3v2) / `UNSYNCEDLYRICS` (Vorbis) / `©lyr` (MP4) frame via lofty. Same Windows file-lock dance as the tag editor — pause if the engine has the file open, then re-hash with blake3 and update `track.file_hash` so the cache row stays addressable after the write. Emits a typed `lyrics:updated` event the panel listens to. UI is [`LyricsEditorModal`](src/components/common/LyricsEditorModal.tsx) opened via the pencil button in the panel header. Two tabs: a free-form textarea, and a Musicolet-style synced editor where each row is `(timestamp, text)` and a "Capturer" button (Space shortcut) snaps the active row's timestamp to the player's current `positionMs`. The modal pilots the existing `PlayerContext` (play/pause/±2 s) so the user can scrub the file while writing the lines, and the rows are serialised back to LRC via [`serializeLrc`](src/lib/tauri/lyrics.ts). -- **Gapless playback**: [`SharedPlayback::gapless_enabled`](src-tauri/src/audio/state.rs) (default ON, persisted in `profile_setting['audio.gapless']`) drives a sample-accurate hand-off between consecutive queued tracks when `crossfade_ms == 0`. The decoder pre-fetches the next track ~500 ms before EOF (same `AnalyticsMsg::PrefetchNext` flow as crossfade), then in [`audio/decoder.rs::play_track`](src-tauri/src/audio/decoder.rs) the EOF branch swaps `pending_next` into `stream` in-place and re-uses the existing `CrossfadeStarted` analytics message so the queue cursor + play_event get the same treatment as a crossfade swap. Crossfade wins when both are enabled because the fade implicitly subsumes the gap. Toggle exposed via `player_set_gapless`. -- **Advanced search**: [`search_tracks_advanced`](src-tauri/src/commands/track.rs) layers structured filters (genres, year/BPM/duration ranges, formats, Hi-Res, liked-only) on top of the FTS5 `track_fts` virtual table. Every filter is optional and AND-combined; multi-value filters (`genre_ids`, `formats`) are OR-combined inside themselves. The query string itself is optional too — passing only filters runs a pure-filter browse limited to 200 rows ordered canonically (Artist → Album → Disc → Track), while supplying a query restores FTS rank ordering. The frontend filter panel ([`src/components/layout/TopBar.tsx`](src/components/layout/TopBar.tsx)) lazy-loads the genre list on first open and re-runs the search whenever any filter changes. -- **Sleep timer**: [`useSleepTimer`](src/hooks/useSleepTimer.ts) is a frontend hook (no persistence — sleep state surviving a restart would be confusing). Two modes: duration (5/15/30/45/60/90 min presets + custom up to 12 h) and "end of current track" (listens for the `player:track-ended` Tauri event). The end-of-track mode arms a backend flag (`SharedPlayback::pause_after_current_track`, exposed via `player_set_pause_after_track`) which the analytics worker checks via `swap(false)` BEFORE running its auto-advance step — without this the next track starts before the frontend pause can race the auto-advance LoadAndPlay. On expiry, fades the volume from its current level to 0 over 5 s (50 ticks of 100 ms via `setVolume`), pauses, then restores the original volume so the next session starts at the user's chosen level. UI in [`SleepTimerMenu`](src/components/player/SleepTimerMenu.tsx) — moon-icon trigger in the player bar with a live countdown badge. -- **First-run onboarding**: `AppLayout` latches a single decision per profile via `profile_setting['onboarding.dismissed']`. 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. -- **Playlist sort modes**: Spotify-style dropdown above the track table — Custom order / Title / Artist / Album / Recently added / Duration. Persisted per-playlist via [`useSortMemory`](src/hooks/useSortMemory.ts) keyed `sort.playlist:` so each playlist remembers its own preference and switching back to "Custom order" restores the stored drag-and-drop arrangement verbatim. **Non-custom modes are display-only** — the sort happens client-side via `Intl.Collator` (locale-aware, numeric-friendly) over the existing `tracks` state and never touches `playlist_track.position` in the DB, so the user's curated order is recoverable just by flipping the dropdown back. Drag handles are hidden and `useSortable` is `disabled` when the mode isn't `custom` — preventing a stray drag from silently mutating positions out of order with what the user sees. Playback (`handlePlayAll` / `handleShufflePlay` / `handlePlayTrackByIndex`) enqueues the _displayed_ order, not the stored one, so "next" makes sense in any sort mode. The dropdown waits for `useSortMemory.isLoaded` before mounting so the active option doesn't flash from "Custom" to the persisted value on first paint. -- **General preferences (autostart / close-to-tray / scan-on-start)**: [`commands/preferences.rs`](src-tauri/src/commands/preferences.rs) owns the three Settings → Général toggles that shipped in 1.0 without backend wiring (their `useState` defaults were thrown away on every restart, none of the side effects fired — first bug reported by an external user, GitHub issue #15). **Autostart** is delegated to [`tauri-plugin-autostart`](https://v2.tauri.app/plugin/autostart/) — the OS owns the state (registry key on Windows, LaunchAgent on macOS, `~/.config/autostart/*.desktop` on Linux) and the launcher gets `--minimized` so it surfaces in the tray instead of stealing focus. **Close-to-tray** lives in `app_setting['app.minimize_to_tray']` (default `true` — preserves the 1.0 behaviour) and is hydrated at boot into a `PreferencesState::minimize_to_tray` `AtomicBool` so the `WindowEvent::CloseRequested` handler in [`lib.rs`](src-tauri/src/lib.rs) is a single atomic load; when OFF the handler arms the `QuitGate` so the upcoming `Destroyed` event runs the normal shutdown path (resume-point persist + audio engine shutdown). Mini-player closes are exempt — the `window.label() != "main"` check returns early so closing the PIP only closes the PIP. **Scan-on-start** lives in `profile_setting['library.scan_on_start']` (default OFF — opt in so terabyte libraries don't pay the I/O every launch) and is consumed by a fire-and-forget tokio task spawned at the end of the setup hook that walks every `library_folder` row and calls the same `scan_folder_inner` path as the manual rescan button, so the existing `scan:progress` toast surfaces with zero new wiring. -- **Page-level scrolling**: virtualized tables in `PlaylistView` / `LibraryView` consume `usePageScroll()` for the scroll element instead of nesting their own `overflow-y-auto`. They compute `scrollMargin` from the parent's offset within the page scroller so `useVirtualizer` knows where their content begins. Drives a single Spotify-style scrollbar. -- **Right panels are flex siblings, not overlays**: `NowPlayingPanel` / `QueuePanel` / `LyricsPanel` are mounted as flex children of the outer row in `AppLayout`, not as `absolute` overlays inside the center column. The center column has `min-w-0` so wide tables collapse instead of pushing the panel off-screen — Spotify-style responsive shrink instead of overlap. `DeviceMenu` and `NowPlayingChevronTab` stay inside the center column so their `right-0` anchors to the content edge. -- **Output device naming on cpal 0.17**: `device.description().name()` returns Windows' generic `DEVPKEY_Device_DeviceDesc` (literally `"Speakers"` for every speaker-class endpoint). [`audio/output.rs::device_display_name`](src-tauri/src/audio/output.rs) prefers `description().extended()[0]` (the disambiguated `DEVPKEY_Device_FriendlyName`) when available — required so multiple endpoints in the same class stay distinguishable in the picker AND so `profile_setting['audio.output_device']` matches a unique device on next boot. -- **Smart playlist covers**: `playlist.cover_hash` (added in migration `20260509000000`) points into the shared `metadata_artwork/.jpg` cache. `list_playlists` / `get_playlist` derive `cover_path` post-fetch via `metadata_artwork::existing_path` so a stale hash (cache wiped) doesn't render a broken image. The "Daily Mix N" label is rendered in HTML/CSS over the JPEG by the frontend — text is intentionally not rasterised in Rust to avoid a font dep and let translations / restyles regenerate without a re-render pass. -- **User playlist auto-covers**: `playlist.cover_is_auto` (added in migration `20260509100000`) tells the post-mutation hook (`commands::playlist_cover::maybe_regen_auto_cover`, called by every add/remove/reorder/source/import command) whether to refresh the cover from the first 4 tracks' album artworks (Spotify-style 2×2 grid via the same `smart_playlists::cover::build_composite_cover` compositor). Switches to `0` when the user uploads a manual image via `set_playlist_cover_from_file`; flips back to `1` (and immediately re-runs the auto pipeline) on `clear_playlist_cover`. Smart playlists (`is_smart=1`) are excluded from this hook to keep ownership clean. Input artwork hashes are **deduplicated in `top_track_artwork_paths`** before the compositor sees them — a playlist whose first N tracks share an album cover collapses to a single full-canvas image instead of a 2×2 grid of identical thumbnails, matching Spotify's mono-album behaviour. The compositor's layout selector (1 → full / 2 → halves / 3 → strips / 4+ → grid) then picks the right look from the _unique_ cover count, so 4 tracks across 2 albums still produce a clean two-half composite. -- **Full-width music views**: HomeView / LibraryView / PlaylistView / AlbumDetailView / ArtistDetailView / LikedView / RecentView / StatisticsView render full-width inside the center column (no `max-w-*` cap). Track tables are borderless (no `rounded-2xl border bg-white` wrapper) so rows feel "on the page" Spotify-style. Form-style views (Settings, About, Feedback) keep `max-w-4xl` for line-length comfort. -- **Embedded changelog**: [`src-tauri/build.rs`](src-tauri/build.rs) shells out to `git log` once at compile time, parses each subject as a conventional commit (`type(scope): subject`, optional `!` for breaking) and writes the result to `$OUT_DIR/changelog.json`. The Rust binary embeds it via `include_str!` so the shipped app shows its own changelog without git installed at runtime. Tarball builds (no `.git/`) silently degrade to `[]`. Frontend command is `get_changelog` ([`commands/changelog.rs`](src-tauri/src/commands/changelog.rs)); the AboutView keeps only user-facing types (`feat`, `fix`, `perf`, `refactor`) and folds the rest under "Show more". -- **Process-wide offline mode**: [`src-tauri/src/offline.rs`](src-tauri/src/offline.rs) exposes a `static AtomicBool` consulted by every outbound HTTP path (Deezer enrichment + batches, Last.fm now-playing + scrobbler tick, similar artists, LRCLIB lyrics fetch + prefetch). Persisted in `app_setting['network.offline_mode']` (shared across profiles — offline is a network-stack concern, not a per-profile preference) and hydrated by [`AppState::init`](src-tauri/src/state.rs). Each gated command short-circuits to either an empty payload or whatever the cache holds; nothing errors so the UI degrades silently. Toggle exposed via `get_offline_mode` / `set_offline_mode` and wired into Settings → Intégrations. -- **Configurable keyboard shortcuts**: [`src/lib/shortcuts.ts`](src/lib/shortcuts.ts) defines the action enum + default bindings, [`src/hooks/useGlobalShortcuts.ts`](src/hooks/useGlobalShortcuts.ts) attaches a single `window.keydown` listener (mounted once in [`AppLayout`](src/components/layout/AppLayout.tsx)) that dispatches against `PlayerContext`. Listener skips when the focus target is `INPUT` / `TEXTAREA` / `contenteditable`. Bindings are stored per-profile in `profile_setting['ui.shortcuts']` as a JSON object containing only the user's overrides (defaults stay implicit so future default tweaks propagate to any action the user hasn't customised). The Settings card ([`ShortcutsCard`](src/components/views/settings/ShortcutsCard.tsx)) captures keys in capture-phase so its rebind doesn't fire the global handler. AboutView reads the same setting and listens to `waveflow:shortcuts-changed` so the displayed combos stay live without polling. -- **Profile export / import (`.waveflow` archive)**: [`commands/profile_io.rs`](src-tauri/src/commands/profile_io.rs) bundles a profile's `data.db` + `artwork/` directory + a `manifest.json` (archive_version / app_version / source profile name + id / exported_at) into a single zip via [the `zip` crate](https://crates.io/crates/zip) with deflate compression. Exporting the **active** profile runs `PRAGMA wal_checkpoint(TRUNCATE)` first so the bundled DB captures every committed page; CPU-bound zip work runs on `tokio::task::spawn_blocking` so the runtime stays responsive on multi-GB libraries. Import allocates a fresh profile row (always — no overwrite path), extracts under `profiles//`, then opens the imported pool once so any pending sqlx migrations replay before the user switches to it. The shared `app.db` (Last.fm key, Discord opt-in) and the `metadata_artwork/` cache are deliberately **not** bundled — the former belongs to the install, the latter is re-fetchable. -- **Immersive Now Playing overlay**: [`FullscreenNowPlaying`](src/components/player/FullscreenNowPlaying.tsx) is a sibling of the PlayerBar wrapper (mounted as a `<>` fragment, not nested inside the bar) so its `fixed inset-0 z-100` overlay sits above the side panels too. Reuses the regular `PlaybackControls` + `ProgressBar` + `VolumeControl` so behaviour stays identical to the bottom transport — no duplicate state. Triggered by clicking the cover in the player bar OR the Maximize2 icon next to the lyrics toggle; closed by Escape or the X button. State is purely local to the bar (`useState`) — no other view queries it, so there's no point promoting it to `PlayerContext`. -- **Listening history (Last.fm-style scrubber)**: [`HistoryView`](src/components/views/HistoryView.tsx) replaces the old simple "recently played" list. Backed by two new commands in [`commands/browse.rs`](src-tauri/src/commands/browse.rs): `list_play_history` returns one row per `play_event` (no per-track dedup, opposite of `list_recent_plays`) with a `before_ms` cursor for infinite scroll and an `after_ms` lower bound for date-range filters; `play_history_months` aggregates `(year, month, count, start_ms)` for the right-side timeline scrubber. UI groups consecutive rows by local-time day with sticky `
` headers, paginates 100 rows at a time via an `IntersectionObserver` on a sentinel div, and renders a vertical month list on the right that lets the user re-anchor the cursor at the end of any historical month and reload from there. The "recent" sidebar entry now routes to this view — same data, deeper navigation. -- **Library scanner — transactions + parallel extraction**: [`scan_folder_inner`](src-tauri/src/commands/scan.rs) splits work into two phases. Phase 1 (serial fast path) classifies every file via cheap `fs::metadata` against the prefetched `(path → mtime+size)` snapshot — files that match the DB are pushed straight through as `skipped` without any extraction. Phase 2 streams the remaining files through a parallel pipeline: [`futures::stream::buffered(N)`](https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html#method.buffered) with `N = std::thread::available_parallelism()` keeps that many `tokio::task::spawn_blocking(extract_file)` tasks in flight at once, saturating multi-core CPUs on the BLAKE3 hash without overwhelming the kernel I/O scheduler. The consumer side is single-threaded (SQLite WAL allows only one writer) and rides inside a `pool.begin()` transaction that commits every 200 rows so the WAL doesn't balloon AND a failure mid-scan only loses at most one batch instead of the whole import. The upsert helpers (`upsert_artwork` / `upsert_artist` / `upsert_album` / `upsert_genre`) now take `&mut sqlx::SqliteConnection` instead of `&SqlitePool` so they participate in the open transaction — no more pool-clone tricks. `edit.rs` was updated to match (its existing `tx` now hosts the upserts directly, removing the commit/reopen dance). End-to-end speedup on a fresh 800-track import: ~25 s → ~5-7 s on a 4-core box. -- **Library scan progress + perf**: [`scan_folder_inner`](src-tauri/src/commands/scan.rs) accepts an `Option<&AppHandle>` and emits `scan:progress` Tauri events (`{ folder_id, current, total, added, updated, skipped, errors, done }`) every 25 processed files plus once at start and once at end. Frontend listener is the global [`ScanProgressToast`](src/components/common/ScanProgressToast.tsx) mounted in AppLayout — non-blocking bottom-right toast with progress bar; flips to a "done" card for ~4 s when the scan finishes. Three perf wins land alongside: (1) `synchronous=NORMAL` on the SQLite pool ([db/profile_db.rs](src-tauri/src/db/profile_db.rs)) — fsync per checkpoint instead of per commit, ~5× speedup on cold disks for bulk inserts; (2) a re-scan fast path that compares `(file_modified, file_size)` from a pre-fetched `HashMap` against the disk metadata BEFORE calling `extract_file`, skipping the BLAKE3 hash + lofty tag re-read entirely when nothing changed (an unchanged 800-track folder drops from ~30 s to <1 s); (3) the watcher's silent rescans share the same code path so `scan:progress` flows even for fs-watcher-triggered scans. The progress hint is fire-and-forget — emit failures never abort a scan. -- **Modal accessibility**: Every modal in [`src/components/common/`](src/components/common/) (CreatePlaylistModal, CreateLibraryModal, LyricsEditorModal, TrackPropertiesModal, SmartPlaylistEditorModal, DuplicatesModal, OnboardingModal, ProfileSelectorModal, CoverPickerModal, Lightbox) plus [`FullscreenNowPlaying`](src/components/player/FullscreenNowPlaying.tsx) wires through [`useModalA11y`](src/hooks/useModalA11y.ts) — a single hook that handles Escape-to-close, Tab/Shift+Tab focus trapping, and focus restoration on close. The container div gets `role="dialog"` + `aria-modal="true"` + either `aria-labelledby` (when a stable heading id exists) or `aria-label` (when the heading is conditionally rendered). Don't roll bespoke `useEffect` Escape handlers in new modals — call `useModalA11y(isOpen, onClose)` and attach the returned ref to the container. A global `:focus-visible` fallback in [`app.css`](src/app.css) gives every interactive element a 2 px emerald outline at zero specificity, so per-button `focus-visible:ring-*` classes still win when present. -- **Smart crossfade (album-aware)**: A `SharedPlayback::smart_crossfade_enabled` toggle (default OFF, opt-in, `profile_setting['audio.smart_crossfade']`) suppresses the fade between two tracks of the same album so concept records / live sets aren't smeared by the equal-power mix. Implementation is one atomic + one DB round trip in the analytics `PrefetchNext` handler: it looks up `current.album_id` + `next.album_id`, writes the bool to `SharedPlayback::pending_next_same_album` right before dispatching `SetNextTrack`. The decoder checks both atomics at mix-decision time; when smart-skip applies, it bypasses the mix branch and falls through to the existing gapless EOF swap. The hint is naturally one-shot — each new prefetch overwrites it, and manual `LoadAndPlay` paths never consult it. -- **Dynamic crossfade (tempo-aware)**: A `SharedPlayback::dynamic_crossfade_enabled` toggle (default OFF, opt-in, `profile_setting['audio.dynamic_crossfade']`) scales each fade by the BPM gap between two tracks. Analytics `PrefetchNext` reads `track_analysis.bpm` for current + next; when both are known, it picks a factor (≤8 BPM → 100%, ≤20 → 75%, ≤40 → 50%, else 30%), applies a 1500 ms floor (clamped against the base) and writes the result to `SharedPlayback::pending_next_crossfade_ms` right before `SetNextTrack`. The decoder reads the override as the effective `cf_ms` when non-zero and clears it the instant the mix actually starts. Either BPM unknown → no override, decoder falls back to the static `crossfade_ms`. Smart crossfade still wins for same-album hand-offs. -- **Spectrum visualizer (FFT)**: [`audio/spectrum.rs`](src-tauri/src/audio/spectrum.rs) lives on the decoder thread, fed with post-EQ samples right before they hit the SPSC ring (cpal callback is never touched). 2048-pt windowed real FFT via `realfft`, 50% overlap, 48 log-spaced bands from 30 Hz to 16 kHz, throttled to ~30 Hz. Gated behind `SharedPlayback::visualizer_enabled` (atomic `bool`) so when the toggle is off, `SpectrumAnalyzer::feed` short-circuits at the first atomic load — zero allocations, zero FFT cost. Frames are emitted as `player:spectrum` Tauri events (`Vec` in 0..1, peaks may briefly overshoot). Frontend [`SpectrumVisualizer`](src/components/player/SpectrumVisualizer.tsx) is a Canvas with `requestAnimationFrame` decay (jump up fast, fall slow) so the visual feels fluid even though the source cadence is sub-screen. Persisted in `profile_setting['ui.visualizer']`, default OFF; mounted inside [`FullscreenNowPlaying`](src/components/player/FullscreenNowPlaying.tsx) above the progress bar. -- **Playback speed (0.5×–2×)**: resampler-shift — the decoder feeds rubato a fake source rate of `actual_rate * speed` so each cpal output sample represents `speed` source samples of audio. Pitch follows speed (no time-stretching). State on `SharedPlayback`: `playback_speed_bits` (`AtomicU32` f32 bits, clamped) + `speed_dirty` (one-shot flag). The decoder polls dirty once per `'pkt` loop and calls `ActiveStream::rebuild_resampler` on the primary + crossfade prefetched secondary, then clears the local already-resampled buffers and runs `drain_silent` so the audible transition is < 20 ms. `set_playback_speed` rebases `samples_played`/`base_offset_ms` against the position **at the old speed** before flipping the atomic, so the progress bar stays continuous across the change. Both `current_position_ms` and `session_listened_ms` multiply by speed, so analytics credit fires on track-time covered, not wall-clock — listening to a 6 min track at 2× counts as 6 min for the heatmap. Persisted in `profile_setting['audio.playback_speed']`. UI lives inside the player-bar overflow ("⋯") menu (range slider + 5 presets); the "⋯" trigger surfaces a `1.25×` emerald badge when speed ≠ 1× so the user keeps live feedback without opening the menu. -- **Player-bar overflow menu**: [`MoreActionsMenu`](src/components/player/MoreActionsMenu.tsx) is the "⋯" popover that hosts every secondary player action so the right side of the bar stops growing every time we ship a feature. Default contents: playback speed (range slider + 5 presets), A-B loop (a labelled row wrapping the standalone [`AbLoopButton`](src/components/player/AbLoopButton.tsx)) and the full sleep-timer panel (presets grid + end-of-track + custom-minutes form — duplicated inline rather than mounting a nested popover, since a popover-in-popover breaks outside-click detection). Primary slot: Lyrics, Queue, Device picker, "⋯", Volume + the Spotify-style right cluster (Mini-player, Fullscreen). Users can **pin** Sleep timer / A-B loop to the bar from Settings (`profile_setting['ui.show_sleep_timer' | 'ui.show_ab_loop']`, default OFF) — when pinned, the entry is omitted from the overflow menu so it isn't duplicated. The "⋯" trigger surfaces a single badge that prioritises the sleep-timer countdown when armed, otherwise the current playback speed if ≠ 1× — so the user keeps live feedback without opening the menu. The trigger auto-hides only when nothing inside would render (Spotify mode + both pinnable entries pinned). When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage / feedback warrants it, and add a pin toggle if both modes make sense. -- **Auto-backup**: [`backup.rs`](src-tauri/src/backup.rs) is a single tokio task spawned at boot that reads `app_setting['backup.enabled' | 'backup.interval_days' | 'backup.folder' | 'backup.retention' | 'backup.last_run_at']` and either parks on a `tokio::sync::Notify` (disabled) or sleeps until `last_run_at + interval_days * 86_400_000`. On wakeup, [`run_one_backup`](src-tauri/src/backup.rs) iterates every row in `profile`, calls the shared `profile_io::write_archive` (pub-crate-ified to keep auto + manual exports bit-compatible) into `-.waveflow`, then prunes per-profile archives beyond `retention` by mtime. Per-profile errors are logged but don't abort the pass. `set_backup_config` signals the same `Notify` so the loop reconfigures within seconds, not at the next deadline. Default folder is `/waveflow/backups/`. UI: [`BackupCard`](src/components/views/settings/BackupCard.tsx) in Settings → Stockage right after the manual export/import block. -- **Stats JSON export**: [`commands/stats.rs::export_stats_json(range, target_path)`](src-tauri/src/commands/stats.rs) bundles overview + top 100 tracks / artists / albums + listening-by-day + listening-by-hour into a versioned (`schema_version: 1`) pretty-printed JSON file. Writes the file from Rust via `spawn_blocking` so we don't depend on `tauri-plugin-fs` just to round-trip a string between frontend and disk. UI: Download button next to the range selector in StatisticsView. -- **WaveFlow Wrapped (year-in-review)**: [`commands/wrapped.rs::get_wrapped(year)`](src-tauri/src/commands/wrapped.rs) bundles every aggregate the Spotify-Wrapped clone needs into a single payload — overview (plays / minutes / unique tracks / artists / albums), top 10 tracks + artists + top 5 albums (sharing the existing stats row shapes so the frontend's artwork resolver works unchanged), per-month + per-hour histograms, most active day, mood profile (listening-weighted average BPM + LUFS, with a 5-tier energy label derived from BPM buckets), first listen of the year, and longest consecutive-day listening streak. Year bounds are computed in **local time** (Jan 1 00:00 → Dec 31 23:59:59, exclusive upper) so a play at 11:59 PM on Dec 31 lands in the right year regardless of UTC offset. `available_wrapped_years` enumerates every year that has at least one `play_event` row, sorted DESC, so the year picker doesn't list empty years. Frontend: [`WrappedView`](src/components/views/WrappedView.tsx) is a story-style overlay (`fixed inset-0 z-100`, similar pattern to `FullscreenNowPlaying`) with 10–12 auto-advancing slides (intro → minutes → top tracks → top artists → top albums → most active day → mood → listening clock → streak → first listen → months → outro). Slides without data are filtered out of the rotation (no analysed track → mood slide skipped; no streak ≥ 2 days → streak slide skipped) so the experience tightens to what the profile actually has. Top-of-screen progress segments + space-to-pause + arrow-key navigation match Instagram / Snapchat story conventions. Entry point: a banner on [`HomeView`](src/components/views/HomeView.tsx) that only renders when `available_wrapped_years` returns ≥ 1 year, gradient-styled to match the overlay's accent so the transition feels continuous. **Shareable PNG**: the overlay's top-bar Share button opens a Save / Copy menu. [`lib/wrappedCard.ts`](src/lib/wrappedCard.ts) builds a 1080×1920 portrait PNG on an offscreen Canvas (radial-gradient backdrop sampled from the year accent, big year + minutes number, top 5 tracks with cover thumbnails, mood + streak strip), then the Save path ships the bytes to [`save_share_image`](src-tauri/src/commands/share.rs) which writes via `spawn_blocking`, while Copy uses `navigator.clipboard.write` with `ClipboardItem("image/png")`. No font file in the bundle — text uses the WebView's native stack. -- **Now Playing share card**: same Save / Copy menu pattern as Wrapped, mounted in [`FullscreenNowPlaying`](src/components/player/FullscreenNowPlaying.tsx)'s top bar. [`lib/nowPlayingCard.ts`](src/lib/nowPlayingCard.ts) renders a 1080×1080 square PNG: cover artwork full-bleed under a dark wash for the background, then re-rendered as a centred 580 px tile with rounded corners + drop shadow, title + artist + album stacked below, plus a thin accent strip at the bottom in the artwork's dominant colour (sampled via [`lib/dominantColor.ts`](src/lib/dominantColor.ts)) so each card visually nods to its source cover. Backend write goes through the same [`save_share_image`](src-tauri/src/commands/share.rs) Tauri command — the IPC channel is feature-agnostic so future share-card flows (album, playlist) can reuse it without new commands. -- **Track ratings (POPM round-trip)**: [`commands/track.rs::set_track_rating`](src-tauri/src/commands/track.rs) writes back to both the DB (`track.rating`, POPM 0-255 scale) AND the file's tag via lofty — binary POPM frame `\0` for ID3v2 (MP3 / WAV / AAC / AIFF), text `RATING=0-100` for Vorbis / MP4 / APE. Pauses playback if the engine has the file open (Windows rename needs an exclusive handle, same dance as `edit::update_track_tags`). File-write errors are non-fatal — DSD has no writable rating frame, so the DB-only fallback preserves the rating without surfacing an error popup; the scanner's `(mtime, size)` fast path never re-extracts unchanged files, so the DB rating sticks across rescans. Emits `track:updated` so [`useTrackUpdated`](src/hooks/useTrackUpdated.ts) consumers re-fetch without polling. UI surfaces: inline [`StarRating`](src/components/common/StarRating.tsx) in LibraryView's track list, integer-star submenu in [`TrackContextMenu`](src/components/common/TrackContextMenu.tsx) (auto-wired through [`useTrackContextMenu`](src/hooks/useTrackContextMenu.tsx) so every view picks it up), half-step widget in [`TrackPropertiesModal`](src/components/common/TrackPropertiesModal.tsx). Smart playlists expose this as the `rating_min` predicate in [`CustomRules`](src-tauri/src/smart_playlists/custom.rs); the editor's star picker stores `Math.round(stars / 5 * 255)`. +Rust / Tauri 2. Entry: `src-tauri/src/main.rs` → `lib.rs`. + +- **Commands** (`src-tauri/src/commands/`): organized by domain — `library.rs`, `playlist.rs`, `smart_playlists.rs`, `track.rs`, `browse.rs`, `player.rs`, `scan.rs`, `edit.rs`, `profile.rs`, `analysis.rs`, `deezer.rs`, `similar.rs`, `lyrics.rs`, `stats.rs`, `wrapped.rs`, `integration.rs`, `maintenance.rs`, `app_info.rs`, `radio.rs`, `mood_radio.rs`, `duplicates.rs`, `preferences.rs`, `share.rs`, `changelog.rs`, etc. All registered in `lib.rs` via `generate_handler![]`. +- **External API clients** (crate-root modules): `deezer.rs` (public Deezer, no auth), `lastfm.rs` (`artist.getInfo`, user API key required). Both use `reqwest` with `rustls-tls`. +- **Audio engine** (`src-tauri/src/audio/`): 3-thread lock-free architecture — `decoder.rs` (symphonia + rubato), `output.rs` (cpal callback on a dedicated thread, SPSC `rtrb` ring buffer), `state.rs` (`SharedPlayback` with atomics, no locks in hot path), `analytics.rs` (tokio task for `play_event` writes + auto-advance), `crossfade.rs`, `eq.rs`, `spectrum.rs`, `wasapi_exclusive.rs` (Windows-only opt-in), and `dsd/` (in-house DSF/DFF parser + DSD→PCM converter, Symphonia 0.5 doesn't decode DSD). Deep dive: [`docs/features/playback.md`](docs/features/playback.md). +- **DLNA / UPnP MediaServer** (`src-tauri/src/dlna/`): worker thread → axum HTTP server + SSDP announcer. Opt-in (`app_setting['dlna.enabled']`, default OFF). See [`docs/features/dlna.md`](docs/features/dlna.md). +- **OS media controls** (`media_controls.rs`): souvlaki bridge → SMTC / MPRIS / MediaRemote. Initialized post-window (needs HWND on Windows). +- **Discord Rich Presence** (`discord_presence.rs`): named-pipe IPC, opt-in `app_setting['integrations.discord_rpc']` (default ON). See [`docs/features/integrations.md`](docs/features/integrations.md#discord-rich-presence). +- **Queue** (`queue.rs`): persistent queue with fill / advance / shuffle (Fisher-Yates) / restore. +- **Smart playlists** (`smart_playlists/`): Daily Mix generator + composite cover renderer. See [`docs/features/smart-playlists.md`](docs/features/smart-playlists.md). +- **Database**: per-profile SQLite via sqlx + a global `app.db` for the profile list and app-wide settings (`app_setting`). + +## Cross-cutting rules (always apply) + +These bite you if you ignore them — they're the contract the rest of the codebase is built on. + +- **Tauri commands**: `#[tauri::command]` in `commands/*.rs`, registered in `lib.rs::generate_handler![]`, called from React with `invoke("command_name", { args })`. Frontend camelCase, backend snake_case. +- **Profile-scoped pool**: `state.require_profile_pool().await?` — every command that touches user data goes through this. The shared `app.db` is for the profile list + cross-profile settings (Last.fm key, Discord opt-in, offline mode, backup config). +- **Persistence**: per-profile settings live in `profile_setting` (key-value, typed). Pattern: `INSERT ... ON CONFLICT DO UPDATE`. App-wide settings live in `app_setting` with the same shape. +- **Events**: backend emits Tauri events (`player:state`, `player:position`, `player:track-changed`, `player:queue-changed`, `player:error`, `player:ab-loop`, `player:spectrum`, `track:updated`, `library:rescanned`, `scan:progress`, `lyrics:updated`, …). Frontend listens via `listen()` from `@tauri-apps/api/event`. +- **Audio callback is hot**: the cpal callback (and the WASAPI exclusive thread) MUST NOT allocate, lock, or log. Only `rtrb::Consumer` reads + `Atomic*` loads. All heavy work (EQ, ReplayGain, resampling, FFT, BLAKE3) runs on the decoder thread before samples reach the SPSC ring. +- **Migrations are immutable once merged**: sqlx records a SHA-384 checksum in `_sqlx_migrations.checksum` at apply time, so editing a merged migration crashes every existing install at boot with `"migration was previously applied but has been modified"` (no auto-recovery — user has to wipe their data dir). For any schema evolution, **create a new dated migration** `YYYYMMDDhhmmss_.sql`. Same rule for `migrations/app/`. +- **Virtual scroll everywhere**: TrackTable uses `@tanstack/react-virtual` for 6000+ track performance. Virtualized tables consume `usePageScroll()` for the scroll element instead of nesting their own `overflow-y-auto` — drives a single Spotify-style scrollbar. +- **Multi-artist queries**: the scanner splits `"A, B"` on `", " / "; "` into individual `artist` rows linked via `track_artist`. Queries rebuild the display string via `GROUP_CONCAT` over `track_artist` ordered by `position`. `ArtistLink` accepts parallel `artist_name` + `artist_ids` strings so every contributor is individually clickable. New track queries must follow the same join pattern. +- **Album grouping = `(canonical_title, album_artist_id)`**: [`scan.rs::upsert_album`](src-tauri/src/commands/scan.rs) keys on the album artist (Album Artist tag → `is_compilation` → primary artist fallback). `album.is_compilation` is sticky and `merge_implicit_compilations` collapses ≥ 3 distinct-artist same-title rows into "Various Artists" after every scan. `edit.rs` re-runs `upsert_album` with the OLD album's Album Artist / compilation flags so renames don't re-split. Deep dive: [`docs/features/library.md`](docs/features/library.md#album-grouping). +- **Single writer to SQLite**: WAL mode allows concurrent reads but only one writer. Big import paths (`scan_folder_inner`, `edit.rs::update_track_tags`) wrap work in `pool.begin()` + commit every 200 rows. Upsert helpers (`upsert_artwork` / `upsert_artist` / `upsert_album` / `upsert_genre`) take `&mut sqlx::SqliteConnection` so they participate in the open transaction — never a pool clone mid-tx. +- **File-write safety on Windows**: any command that rewrites an audio file (`edit::update_track_tags`, `save_lyrics`, `set_track_rating`) MUST pause playback first when the engine reports the edited track as `current_track_id` — lofty's `save_to_path` needs an exclusive handle on Windows. Re-hash with blake3 + update `track.file_hash` after the write so the scanner's `(mtime, size)` fast path stays addressable. +- **Modal accessibility**: every modal calls [`useModalA11y(isOpen, onClose)`](src/hooks/useModalA11y.ts) — Escape-close, Tab focus trap, focus restoration. Container gets `role="dialog"` + `aria-modal="true"` + `aria-labelledby` (stable heading id) or `aria-label` (conditional heading). Don't roll bespoke `useEffect` Escape handlers. +- **Right panels are flex siblings, not overlays**: `NowPlayingPanel` / `QueuePanel` / `LyricsPanel` are mounted as flex children of the outer row in `AppLayout`. The center column has `min-w-0` so wide tables collapse instead of pushing the panel off-screen. +- **Process-wide offline mode**: every outbound HTTP path (Deezer, Last.fm, similar, LRCLIB) checks `offline::is_offline()` first and short-circuits to an empty payload or cache. Persisted in `app_setting['network.offline_mode']`. Treat new HTTP code paths the same way. +- **Adding a new player-bar action**: default it into the overflow ("⋯") menu via [`MoreActionsMenu`](src/components/player/MoreActionsMenu.tsx) first; promote to primary only when usage warrants it; add a Settings pin toggle if both modes make sense. See [`docs/features/ui.md`](docs/features/ui.md#player-bar-layout). + +## Feature catalogue + +One-liners + doc pointer. For everything else read the actual file in `commands/` or `audio/` — names are predictable. + +### Playback ([`docs/features/playback.md`](docs/features/playback.md)) + +A-B repeat · crossfade (static / smart-album-aware / dynamic-tempo-aware) · gapless · ReplayGain · normalize · mono · 6-band peaking EQ (RBJ biquads, ±12 dB, 20 presets) · playback speed 0.5×–2× (resampler-shift, pitch follows) · DSD → PCM (256-tap Blackman-Harris FIR) · WASAPI Exclusive opt-in (Windows) with transparent fallback to cpal shared · spectrum visualizer (2048-pt FFT, opt-in) · output device persistence + cpal 0.17 friendly-name disambiguation · radio (seed + similar artists + BPM filter) · mood radio (focus/chill/workout/party/sleep) · sleep timer · TXXX:UNSYNCEDLYRICS fallback for MP3 K-Pop/J-Pop rips. + +### Library ([`docs/features/library.md`](docs/features/library.md)) + +Scanner with parallel BLAKE3 extraction + transactional commit + fs-watcher silent rescan + `scan:progress` toast · folder-cover fallback (cover/folder/front/albumart…) · advanced search (FTS5 + structured filters: genre, year, BPM, duration, format, Hi-Res, liked) · tag editor round-trips through lofty + DB · track ratings (POPM round-trip, half-step UI) · duplicate detection (BLAKE3 grouping) · folder removal + drag-and-drop import · listening history (`HistoryView` with month scrubber) · album grouping with sticky compilation flag (see cross-cutting rules above). + +### UI ([`docs/features/ui.md`](docs/features/ui.md)) + +Player bar Spotify-style right cluster (Mini-player + Fullscreen primary, Speed/A-B/Sleep in overflow with pin toggles) · immersive Now Playing overlay · mini-player (`?mini=1` second webview, 280×380, always-on-top, cover-derived gradient background) · karaoke fullscreen lyrics · lyrics editor (plain + Musicolet-style synced) · first-run onboarding (latched per-profile) · WaveFlow Wrapped (year-in-review story overlay + shareable PNG) · Now Playing share card · configurable keyboard shortcuts · full-width music views (no `max-w-*` cap on listing views) · single-instance lock ([`tauri-plugin-single-instance`](https://crates.io/crates/tauri-plugin-single-instance) first plugin in `lib.rs`). + +### Playlists ([`docs/features/playlists.md`](docs/features/playlists.md), [`docs/features/smart-playlists.md`](docs/features/smart-playlists.md)) + +Playlist sort dropdown (custom / title / artist / album / recently added / duration — non-custom modes are display-only via `Intl.Collator`, never touch `playlist_track.position`) · auto-cover (Spotify-style 2×2 grid composite from first 4 tracks; manual upload flips `cover_is_auto=0`) · smart playlists (Daily Mix family + recursive boolean rule tree via `CustomRules`, v1 flat → v2 tree auto-migration) · M3U import/export. + +### Integrations ([`docs/features/integrations.md`](docs/features/integrations.md)) + +Deezer enrichment (pictures, covers, fans — cached 30 days in `deezer_artist` / `deezer_album` in `app.db`, hashes point into shared `metadata_artwork/.jpg` so artwork renders offline) · Last.fm (bios, similar artists, scrobbler) · Discord RPC · DLNA / UPnP MediaServer ([`docs/features/dlna.md`](docs/features/dlna.md)). + +### Preferences & maintenance + +Autostart + close-to-tray + scan-on-start ([`commands/preferences.rs`](src-tauri/src/commands/preferences.rs)) · profile export/import (`.waveflow` zip via `commands/profile_io.rs` — bundles `data.db` + `artwork/` + manifest, runs `PRAGMA wal_checkpoint(TRUNCATE)` on the active profile first) · auto-backup ([`backup.rs`](src-tauri/src/backup.rs) tokio task that shares `profile_io::write_archive` with manual export) · stats JSON export · embedded changelog (parsed from `git log` at compile time in `build.rs`). ## Conventions -- **Conventional commits**: enforced locally via husky `commit-msg` → `bunx commitlint --edit`. Config in `.commitlintrc.cjs` (header ≤ 100, kebab-case scopes). The `prepare: husky` script auto-installs the hook on `bun install`. -- **PR labels**: `.github/workflows/label-pr.yml` auto-applies `scope:*` (path-based via `actions/labeler`), `type:*` (parsed from PR title prefix), and `size:*` (from diff line count) on every PR open/sync. -- **Release & distribution**: release-please owns version bumps. Pushing conventional-commit work to `main` runs [`.github/workflows/release-please.yml`](.github/workflows/release-please.yml), which opens / refreshes a `chore(main): release X.Y.Z` PR that bumps `package.json` (canonical) + `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` + the README version badge (via `x-release-please-version` annotation) + writes `CHANGELOG.md`, and a companion workflow [`release-please-bump-lockfile.yml`](.github/workflows/release-please-bump-lockfile.yml) regenerates `Cargo.lock` on that PR via `cargo check`. Merging the release PR creates the `vX.Y.Z` tag and GitHub Release automatically (release-please owns both — never hand-tag). The tag then fires [`.github/workflows/release.yml`](.github/workflows/release.yml) — Linux (AppImage/.deb/.rpm) + Windows (NSIS/MSI signed Authenticode via `SIGNTOOL_PFX_*` secrets) + macOS (universal DMG, unsigned/un-notarised by default) — and uploads everything to GitHub Releases with a merged `latest.json` updater manifest signed by the minisign key in `TAURI_SIGNING_PRIVATE_KEY`. The final `updater-manifest` job then explicitly `gh workflow run`s the downstream distribution workflows (GitHub silently drops `release: published` events when the release was created by `GITHUB_TOKEN`, so listening for that event alone never fires): [`aur.yml`](.github/workflows/aur.yml) bumps `packaging/aur/PKGBUILD` and pushes to `ssh://aur@aur.archlinux.org/waveflow-bin.git` via `AUR_SSH_PRIVATE_KEY`, [`winget.yml`](.github/workflows/winget.yml) regenerates the manifest from the `packaging/winget//` template and opens a PR against `microsoft/winget-pkgs` via `wingetcreate` using `WINGET_PAT`, and [`copr.yml`](.github/workflows/copr.yml) in-place bumps `packaging/copr/waveflow.spec`, builds an SRPM that repackages the upstream `.rpm`, and submits it to Fedora COPR (`InstaZDLL/waveflow`) via `copr-cli` using `COPR_LOGIN` + `COPR_TOKEN`. Bumping a version means editing **three** manifests in lockstep + regenerating `Cargo.lock` via `cargo check`: [`package.json`](package.json), [`src-tauri/tauri.conf.json`](src-tauri/tauri.conf.json), [`src-tauri/Cargo.toml`](src-tauri/Cargo.toml). Local copies of the four key/cert files live under `secrets/` (gitignored) — see [`docs/RELEASING.md`](docs/RELEASING.md) for the full procedure. -- **Issue + PR templates**: [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/) ships YAML form templates for bugs (with OS / app-version / repro fields) and feature requests; [`.github/pull_request_template.md`](.github/pull_request_template.md) reminds contributors of the conventional-commits scope rules and the `bun run typecheck` / `bun run lint` / `cargo check` triple-check before opening. +- **Conventional commits** enforced locally via husky `commit-msg` → `bunx commitlint --edit`. Config in `.commitlintrc.cjs` (header ≤ 100, kebab-case scopes). `prepare: husky` auto-installs the hook on `bun install`. Subject must NOT be sentence-case / start-case / pascal-case / upper-case — keep it lowercase. +- **PR labels**: `.github/workflows/label-pr.yml` auto-applies `scope:*` (path-based via `actions/labeler`), `type:*` (parsed from PR title prefix), `size:*` (from diff line count). +- **Release & distribution**: release-please owns version bumps and tags. Bumping a version means editing **three** manifests in lockstep + regenerating `Cargo.lock` via `cargo check`: [`package.json`](package.json) (canonical), [`src-tauri/tauri.conf.json`](src-tauri/tauri.conf.json), [`src-tauri/Cargo.toml`](src-tauri/Cargo.toml). The release-please PR handles all of this automatically — **never hand-tag**. Tag push fires [`release.yml`](.github/workflows/release.yml) which builds Linux/Windows/macOS bundles + signed updater manifest, then explicitly `gh workflow run`s downstream `aur.yml` / `winget.yml` / `copr.yml` (GitHub silently drops `release: published` events when created by `GITHUB_TOKEN`). Full procedure: [`docs/RELEASING.md`](docs/RELEASING.md). +- **Issue + PR templates**: `.github/ISSUE_TEMPLATE/` ships YAML form templates (bugs + features). `.github/pull_request_template.md` reminds contributors of the `bun run typecheck` / `bun run lint` / `cargo check` triple-check before opening. ## Language -The README is in English. The app ships UI copy in **17 locales** via i18next — `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`. Strings live in `src/i18n/locales/.json`. 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. +The README is in English. The app ships UI copy in **17 locales** via i18next — `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`. Strings in `src/i18n/locales/.json`. `index.ts` sets `document.documentElement.dir` per language so Arabic renders RTL automatically. + +`fallbackLng: "en"` is set, but the project convention is **every locale carries every key** so the experience stays coherent without language-mixing. When you add a new key, propagate it to all 17 locales (a small Python script using `json.load`/`dump` with `ensure_ascii=False, indent=2` keeps the existing formatting intact). Brand tokens (`WaveFlow`, `Last.fm`, `Deezer`, `ReplayGain`, `LRCLIB`, `BPM`) stay verbatim across locales. Preserve i18next `{{placeholder}}` interpolation tokens unchanged.