Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ One line each. **The reasoning, the failure mode and the exceptions are in [`doc
- **Audio callback is hot** — no allocation, no locks, no logging; all DSP happens on the decoder thread, whose last stage is a `[-1.0, 1.0]` clamp. [→](docs/architecture/invariants.md#the-audio-callback-is-hot)
- **Load commands carry a `LoadIntent`** — claimed when the intent starts (before the awaits), not before the send; the decoder drops any load older than one already delivered, and a producer claims the dispatch before its first side effect, publishing under `lock_publish`. An output rebuild is the one exception: it re-dispatches the decoder's own recorded load, under that load's intent. [→](docs/architecture/invariants.md#every-load-command-carries-its-intent)
- **Anything written into a library folder goes under `.waveflow/`** — `mp4` is an audio extension taken on sight, so a clip anywhere else becomes a track; the scanner prunes that branch and the watcher ignores events inside it. [→](docs/architecture/invariants.md#anything-waveflow-writes-into-a-library-folder-goes-under-waveflow)
- **Library folders never overlap** — no folder is, lies under or holds another (`add_folder_to_library` refuses with `AppError::FolderOverlap`); a drop copies into the managed `Music/WaveFlow` instead of registering where the files lie. Compare with `scanner::folders::relation`, never a string prefix. [→](docs/architecture/invariants.md#library-folders-never-overlap)
- **Never `DROP TABLE` a parent table in a migration** — `foreign_keys = ON` turns it into a cascading delete. Use `ALTER TABLE … ADD COLUMN`. [→](docs/architecture/invariants.md#never-drop-table-a-parent-table-in-a-migration)
- **Migrations are immutable once merged** — sqlx checksums them; always add a new dated `YYYYMMDDhhmmss_<slug>.sql`. [→](docs/architecture/invariants.md#migrations-are-immutable-once-merged)
- **Single writer to SQLite** — batch in transactions, pass `&mut SqliteConnection` to upsert helpers, and any background writer parks behind the scan + retries on `SQLITE_BUSY`. [→](docs/architecture/invariants.md#single-writer-to-sqlite)
Expand Down
6 changes: 6 additions & 0 deletions docs/architecture/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ Both funnel into `resume_last`, which holds a one-at-a-time guard (`AudioEngine:

Two rules that are easy to get wrong: the check is **rooted** (a library that itself lives under a directory of that name is still an ordinary library, and testing every component of an absolute path would empty it), and the walk never prunes **its own root** for the same reason. Writing anywhere else in a library folder needs the same treatment or it will be indexed — including by the watcher, which would otherwise answer our own writes with a rescan. Layout: [`storage.md`](storage.md#inside-the-users-library-issue-695).

### Library folders never overlap

No library folder is, lies under or holds another, in any library of the profile. A folder owns the tracks its scan finds (`track.folder_id`), and removing it deletes them. Two folders where one holds the other both claim the files they share: each scan reads them again, the last to run takes them, and removing either can take tracks the other still shows — with their likes, ratings and playlist places.

`add_folder_to_library` refuses an overlap with `AppError::FolderOverlap`, and the drop never registers a folder at all: it copies into the one folder WaveFlow manages ([drag-and-drop import](../features/library.md#drag-and-drop-import)). Compare folders with [`scanner::folders::relation`](../../src-tauri/crates/core/src/scanner/folders.rs), never with a string prefix: `Music2` starts with the characters of `Music` and is not under it, and Windows and macOS ignore case.

### Never `DROP TABLE` a parent table in a migration

The profile pool opens with `foreign_keys = ON` and SQLite's `DROP TABLE` fires an implicit `DELETE`, so `ON DELETE SET NULL` / `CASCADE` on children run — dropping `artwork` blanks every `album.artwork_id`, dropping `artist` empties `track_artist`. `PRAGMA foreign_keys` is a no-op inside the transaction sqlx wraps migrations in, so it cannot be turned off there. Use `ALTER TABLE … ADD COLUMN` (issue #401 took this route for exactly this reason).
Expand Down
20 changes: 17 additions & 3 deletions docs/features/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Which is why [`analysis_version`](../../src-tauri/migrations/profile/20260909210

Two things follow from it. Until such a row is re-measured, the player treats its peak as a **lower bound** rather than a measurement — refusing the boost it appears to allow while still honouring any attenuation it asks for (see [playback](playback.md#replaygain)). And the background sweep now picks those rows up alongside the never-analysed ones — anything stamped older than the current version — so they heal by being re-measured rather than by being deleted. Re-analysing a track replaces the value with the real LUFS one and stamps the current version.

The background sweep ([`run_analyze_library`](../../src-tauri/crates/app/src/commands/analysis.rs)) is deliberately yielded to the foreground scanner: a scan saturates every CPU core and the single SQLite writer, so the analyzer parks itself while [`scan_in_flight()`](../../src-tauri/crates/app/src/commands/scan.rs) reports an active walk (any of `scan_folder` / `rescan_library` / `import_paths` / the fs-watcher / the startup rescan). It resumes once the scan drains. Decoded results are buffered and flushed to `track_analysis` in batches of 16 inside one transaction, and the flush retries the whole batch on `SQLITE_BUSY` / `SQLITE_LOCKED` with exponential backoff — before this, a per-row `INSERT` racing a concurrent scan would hit `database is locked` after the 5 s busy-timeout and silently drop the freshly-computed BPM / loudness.
The background sweep ([`run_analyze_library`](../../src-tauri/crates/app/src/commands/analysis.rs)) is deliberately yielded to the foreground scanner: a scan saturates every CPU core and the single SQLite writer, so the analyzer parks itself while [`scan_in_flight()`](../../src-tauri/crates/app/src/commands/scan.rs) reports an active walk (any of `scan_folder` / `rescan_library` / `import_dropped` / the fs-watcher / the startup rescan). It resumes once the scan drains. Decoded results are buffered and flushed to `track_analysis` in batches of 16 inside one transaction, and the flush retries the whole batch on `SQLITE_BUSY` / `SQLITE_LOCKED` with exponential backoff — before this, a per-row `INSERT` racing a concurrent scan would hit `database is locked` after the 5 s busy-timeout and silently drop the freshly-computed BPM / loudness.

## Multi-artist

Expand Down Expand Up @@ -241,13 +241,27 @@ The browser itself renders only the directories — as cover tiles or as rows wi

UI: per-folder trash button in the Library → Folders tab, two-step confirm-on-second-click that auto-clears after 3 s.

**Library folders never overlap.** `add_folder_to_library` refuses a folder that is, lies under or holds a folder already in any library of the profile, with `AppError::FolderOverlap` ([why](../architecture/invariants.md#library-folders-never-overlap)). The comparison is by name, as paths are stored ([`scanner::folders`](../../src-tauri/crates/core/src/scanner/folders.rs): `relation`, `overlap`, and `indexing_folder` for the drop): `.` and `..` resolved, a trailing separator ignored, case ignored on Windows and macOS, links not followed. The frontend reads the folder in the way from the message (`parseFolderOverlap`) and [`ImportNoticeToast`](../../src/components/common/ImportNoticeToast.tsx) says it, once, from [`LibraryContext.importFolder`](../../src/contexts/LibraryContext.tsx), so the four places that add a folder (sidebar, Home, Library, onboarding) are covered without each handling it.

## Drag-and-drop import

[`hooks/useDragDropImport.ts`](../../src/hooks/useDragDropImport.ts) wires Tauri 2's window-level `onDragDropEvent` into the existing import flow via a single backend command: [`commands/library.rs::import_paths`](../../src-tauri/crates/app/src/commands/library.rs). The command accepts a mix of folders and audio files — files contribute their parent directory — dedupes the resolved folder set, then for each one tries an `INSERT OR IGNORE INTO library_folder` (the `(library_id, path)` UNIQUE constraint absorbs duplicates) and runs `scan_folder_inner`. Aggregated `ScanSummary` is returned to the frontend so the user sees one toast with the total counts.
[`hooks/useDragDropImport.ts`](../../src/hooks/useDragDropImport.ts) wires Tauri 2's window-level `onDragDropEvent` to [`commands/library.rs::import_dropped`](../../src-tauri/crates/app/src/commands/library.rs), with the rules in [`drop_import`](../../src-tauri/crates/app/src/drop_import.rs).

**A drop is copied into a folder WaveFlow manages, never registered where it lies.** It used to make the folder the files came from a library folder: dropping one file from `Music` added all of `Music`, thousands of tracks nobody asked for, over the folders already in the library. Now the dropped files are copied into `WaveFlow` under the user's music folder (`dirs::audio_dir`, else `~/Music`; the Flatpak has `xdg-music` read-write), and that folder is the library folder. The originals stay where they were.

- **As they are.** A dropped folder keeps its name and its tree under `WaveFlow`, bringing its audio files and the sidecars the app reads (cover images in every format the scanner takes one from, `.lrc`, `.ttml`); a dropped file goes to the root, with its same-name lyrics. A `.lrc` or `.ttml` beside an audio file of the same name follows it under the name the audio ends up with, so a numbered copy keeps its own lyrics, and lyrics already at that name are kept (they may have been edited). Nothing is renamed from its tags. The `.waveflow` branch is skipped, as the scanner skips it.
- **Already in the library is passed over.** An item under a library folder, `WaveFlow` included, has nothing to copy. A folder that holds a library folder is refused: copying it would duplicate that folder's tracks, and dropping the music folder that holds `WaveFlow` would copy it into itself.
- **Copied once.** A file at the target with the same name and size is taken as already copied; one with the same name and another size gets `name (2).ext`. Each copy is written as `<name>.waveflow-part` and renamed when complete, so neither the watcher nor a scan indexes half a file. Choosing the target name and the rename that completes the copy happen under one process-wide lock, so two drops running at once take turns file by file instead of both claiming the same free name. Files already there are still scanned: they may not be indexed (the folder was removed from the library, then the same files dropped again), and a scan passes over unchanged files cheaply.
- **Indexed by one folder.** The copies are scanned by `WaveFlow`'s own row, else by the deepest library folder holding it (a library that is the whole music folder already covers it), else `WaveFlow` is added to the library being imported into. A library folder *inside* `WaveFlow` is an overlap the drop cannot resolve, refused before a byte is copied.
- **Bounded and stoppable.** At most 20 000 files per drop (the lyrics that follow an audio file ride along) and 200 000 entries walked; past that the drop is cut short and says so. The `drop_import` task is announced before the walk, which can take a while on a large folder, so the status bar shows it and its flag stops the walk as well as the copy, between two files; what was copied is still scanned. An entry the walk cannot read is counted as an error rather than skipped in silence.

What the drop did comes back as a `DropImportSummary` (copied, already copied, in the library, refused, failed, cut, stopped, and the scan), which [`ImportNoticeToast`](../../src/components/common/ImportNoticeToast.tsx) shows at the bottom centre — apart from the scan's own card at the bottom right. A notice with something to look at (a refusal, a failure, a cut, nothing copied) stays until dismissed.

Auto-creates a default library on the very first drop when the profile has none, mirroring the existing pickFolder import path.

UI: emerald drop overlay in [`AppLayout`](../../src/components/layout/AppLayout.tsx) renders a fade-in border + drop hint while the user is dragging, then a spinner while the backend scan runs. `pointer-events: none` on the overlay so the drop still hits Tauri's native handler.
**A drop has two targets.** While files are dragged over the window, [`DropTargetsOverlay`](../../src/components/layout/DropTargetsOverlay.tsx) shows **Play now** and **Add to the library** side by side; only the second imports. The first plays the files without importing them ([Playing files without importing them](playback.md#playing-files-without-importing-them)). Tauri reports the pointer in physical pixels from the webview's corner, so the hook divides by `devicePixelRatio` and tests the point against the targets' own boxes, which the overlay registers in a ref: a right-to-left layout that mirrors them needs no special case, and a point in the gap between them goes to the nearer one. The drop goes to the target under the pointer at the drop, else the last one hovered.

The overlay has `pointer-events: none`, so the drop still reaches Tauri's native handler, and goes away on the drop: an import's scan reports in the task status bar like any other scan, where it can be followed and stopped, instead of behind a screen that hid the app for as long as it ran — minutes for a whole music folder. Its base styling works in every skin; the skins on the fluid layout (Fluid, Liquid) recompose it through `data-drop-*` landmarks in their own material (surface cards, the accent ring and the wave mark under the target being hovered).

## Track-list columns

Expand Down
1 change: 1 addition & 0 deletions docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ A file can play without becoming a library track: opened from the system (a doub
- **Otherwise nothing is written.** The files' queue lives in memory on the same `DetachedSession` the remote queue uses ([`remote_playback`](../../src-tauri/crates/app/src/remote_playback.rs)), so it takes over next / previous, media keys and the end of a track while active, under the same ordering rules (#622): nothing is installed for a superseded intent, and an install the user overtook while it started is undone only if still ours. Starting one clears the other; a library track, a radio stream or a profile switch clears both. The sleep timer's "end of this track" stops either queue at the end of the track, as it stops the library one; until now a remote queue played on past it. Each file plays under a fresh negative id, through `AudioCmd::LoadRemoteFileAndPlay` with `DetachedOrigin::File`, so it records no `play_event`, no statistics and no scrobble — the decision for a file played in passing, like radio.
- **Its tags are read when it plays**, not when it is queued (a folder of a thousand files reads one): title (else the file name), artist, length, ReplayGain, and the cover — embedded, else the folder's — written under its hash into the metadata artwork cache, where the asset protocol can serve it. DSD reads its own container; a file whose tags cannot be read still plays, its length from the decoder's probe.
- **On screen** it is told apart by `isFileTrack`: a negative id with a `file_path` (`player:radio-metadata` carries `is_file` and the path; radio and remote tracks leave the path empty). It has no library affordance (`isStreamTrack` covers it: no ♥, no start radio, no slideshow, no file menu), keeps Previous / Next / Repeat like a remote track, no Shuffle, and its queue shows in the remote queue's view (`RemoteQueueView source="file"`, in the panel and the mini-player). Its lyrics are looked up by artist and title and render synced.
- **From the app.** A drop on the window offers two targets, **Play now** and **Add to the library** (see [Drag-and-drop import](library.md#drag-and-drop-import)); the "Play without importing" button in the sidebar's *My music* header opens a menu with **Play files…** and **Play a folder…**, also bound to `Ctrl+O` and `Ctrl+Shift+O`. All of them end in [`playWithoutImporting`](../../src/lib/openToPlay.ts), which hands the paths to `player_play_paths`; the file picker offers the extensions the scanner takes. A failure, from the picker or from the command (a folder with no audio file in it), goes through the playback alert as `files-failed` / `picker-failed`, emitted as a `player:error` from the frontend, since none of these paths has anything else on screen to show it.
- **The command line.** [`launch_args`](../../src-tauri/crates/app/src/launch_args.rs) reads the files a launch was opened with — the first launch from its own arguments once the profile is open, a later one from what the single-instance plugin hands the running instance, relative paths taken from its working directory. Options are skipped; the Flatpak's `%U` arrives as `file://` URIs and is converted.

## A-B loop
Expand Down
2 changes: 1 addition & 1 deletion docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ Hydration on open runs `playerGetState` / `playerGetAudioSettings` / `playerGetE

## Keyboard shortcuts

Action ↔ key bindings live in [`src/lib/shortcuts.ts`](../../src/lib/shortcuts.ts) (14 actions, defaults like `Space` → play/pause, `←`/`→` → previous/next, `M` → mute, `S` → shuffle, `R` → repeat, `L` → toggle lyrics, `Shift+L` → like, `Ctrl+K` → focus the search, `F11` → fullscreen for the whole window). [`useGlobalShortcuts`](../../src/hooks/useGlobalShortcuts.ts) is mounted once in [`AppLayout`](../../src/components/layout/AppLayout.tsx) 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 — except for the actions in `SHORTCUTS_ALLOWED_IN_FIELDS` (`focusSearch`, `toggleFullscreen`): a modifier combo and a function key mean nothing to a field, and Ctrl+K from inside another input is exactly when the jump is wanted. `focusSearch` dispatches `waveflow:focus-search`, which the top bar answers by focusing and selecting its field. `toggleFullscreen` flips the window's own fullscreen; the immersive view's native fullscreen checks `isFullscreen()` before entering and restores only what it changed, so the two compose.
Action ↔ key bindings live in [`src/lib/shortcuts.ts`](../../src/lib/shortcuts.ts) (16 actions, defaults like `Space` → play/pause, `←`/`→` → previous/next, `M` → mute, `S` → shuffle, `R` → repeat, `L` → toggle lyrics, `Shift+L` → like, `Ctrl+K` → focus the search, `F11` → fullscreen for the whole window, `Ctrl+O` / `Ctrl+Shift+O` → play files / a folder without importing them). [`useGlobalShortcuts`](../../src/hooks/useGlobalShortcuts.ts) is mounted once in [`AppLayout`](../../src/components/layout/AppLayout.tsx) 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 — except for the actions in `SHORTCUTS_ALLOWED_IN_FIELDS` (`focusSearch`, `toggleFullscreen`, `openFiles`, `openFolder`): a modifier combo and a function key mean nothing to a field, and Ctrl+K from inside another input is exactly when the jump is wanted. `focusSearch` dispatches `waveflow:focus-search`, which the top bar answers by focusing and selecting its field. `toggleFullscreen` flips the window's own fullscreen; the immersive view's native fullscreen checks `isFullscreen()` before entering and restores only what it changed, so the two compose.

**Recent searches.** Focusing the empty search field shows the last searches the user went somewhere from — a track played, an album or an artist opened from the dropdown — rather than every prefix typed on the way ([`useRecentSearches`](../../src/hooks/useRecentSearches.ts), `profile_setting['ui.recent_searches']`, 8 entries, most recent first, case-insensitive dedupe). A click re-runs one; each can be removed, or all cleared. With an advanced filter set the dropdown shows results as before.

Expand Down
Loading
Loading