diff --git a/CLAUDE.md b/CLAUDE.md index 81cd376cb..a4aefdd59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_.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) diff --git a/docs/architecture/invariants.md b/docs/architecture/invariants.md index 5c136a80e..bed7e23e7 100644 --- a/docs/architecture/invariants.md +++ b/docs/architecture/invariants.md @@ -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). diff --git a/docs/features/library.md b/docs/features/library.md index 29be55746..b054c1266 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -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 @@ -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 `.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 diff --git a/docs/features/playback.md b/docs/features/playback.md index 52bc5f521..58b01cbd7 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -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 diff --git a/docs/features/ui.md b/docs/features/ui.md index 1d9c8576d..0708b523a 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -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. diff --git a/src-tauri/crates/app/src/commands/library.rs b/src-tauri/crates/app/src/commands/library.rs index 17f19fd10..7672540ad 100644 --- a/src-tauri/crates/app/src/commands/library.rs +++ b/src-tauri/crates/app/src/commands/library.rs @@ -407,8 +407,10 @@ pub async fn rescan_library( /// Register an absolute filesystem path as a folder of an existing library. /// /// Returns the `library_folder.id` so the caller can immediately trigger a -/// scan on it. A `(library_id, path)` collision is surfaced as an error so -/// the UI can prompt the user to re-scan the existing folder instead. +/// scan on it. A folder that overlaps one already in any library of the +/// profile -- the same folder, one under it or one holding it -- is refused +/// with [`AppError::FolderOverlap`]: both would claim the tracks they +/// share, and removing either could delete tracks the other still shows. #[tauri::command] pub async fn add_folder_to_library( state: tauri::State<'_, AppState>, @@ -428,9 +430,32 @@ pub async fn add_folder_to_library( ))); } + let pool = state.require_profile_pool().await?; + let folders = all_folder_paths(&pool).await?; + if let Some((relation, existing)) = waveflow_core::scanner::folders::overlap( + std::path::Path::new(&path), + folders.iter().map(|(_, folder)| folder.as_path()), + ) { + return Err(AppError::FolderOverlap { + relation, + existing: existing.to_string_lossy().into_owned(), + }); + } + Ok(repo.insert_folder(library_id, &path).await?) } +/// Every library folder of the profile, in every library. +async fn all_folder_paths(pool: &sqlx::SqlitePool) -> AppResult> { + let rows: Vec<(i64, String)> = sqlx::query_as("SELECT id, path FROM library_folder") + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(id, path)| (id, std::path::PathBuf::from(path))) + .collect()) +} + /// List every folder for a library, with its watch flag. Returned /// straight from `library_folder` so toggling reflects on next fetch /// without going through the heavier `list_folders` aggregation. @@ -442,23 +467,30 @@ pub async fn list_library_folders( Ok(library_repo(&state).await?.list_folders(library_id).await?) } -/// Import a list of arbitrary filesystem paths into a library. -/// Used by the drag-and-drop handler — the user can drop a mix of -/// folders and audio files; we resolve each into a folder path -/// (file → its parent dir), dedupe, then add each as a -/// `library_folder` (skipping duplicates via UNIQUE) and scan it. +/// Add files dropped on the window to a library: copy them into the folder +/// WaveFlow manages under the user's music, then scan it +/// ([`crate::drop_import`] holds the rules). Never registers the folder +/// they came from, which made dropping one file from `Music` add all of it. /// -/// Aggregates every scan's stats into a single `ScanSummary` so the -/// UI can show one toast with the total counts. +/// The copy reports in the task status bar and can be stopped there; the +/// scan that follows reports as any scan does. #[tauri::command] -pub async fn import_paths( +pub async fn import_dropped( app: tauri::AppHandle, state: tauri::State<'_, AppState>, library_id: i64, paths: Vec, -) -> AppResult { +) -> AppResult { + use crate::drop_import::{self, DropImportSummary}; + + let managed = drop_import::managed_folder() + .ok_or_else(|| AppError::Other("no music folder to copy into".into()))?; + let mut summary = DropImportSummary { + folder: managed.to_string_lossy().into_owned(), + ..Default::default() + }; if paths.is_empty() { - return Ok(ScanSummary::default()); + return Ok(summary); } let pool = state.require_profile_pool().await?; let profile_id = state.require_profile_id().await?; @@ -471,84 +503,96 @@ pub async fn import_paths( ))); } - // Resolve each input path into the folder we should add. Files - // contribute their parent directory; non-existent paths are - // skipped silently (the user may have dropped a stale shortcut). - let mut folder_paths: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for raw in paths { - let p = std::path::PathBuf::from(&raw); - let folder = match std::fs::metadata(&p) { - Ok(m) if m.is_dir() => p, - Ok(_) => match p.parent() { - Some(parent) => parent.to_path_buf(), - None => continue, - }, - Err(err) => { - tracing::warn!(path = %raw, %err, "import_paths: stat failed"); - continue; - } + // Settled before a byte is copied: a library folder inside the managed + // one is an overlap the drop cannot resolve, and copying first would + // leave files no folder indexes. + let folders = all_folder_paths(&pool).await?; + let indexing = waveflow_core::scanner::folders::indexing_folder(&managed, &folders).map_err( + |(relation, existing)| AppError::FolderOverlap { + relation, + existing: existing.to_string_lossy().into_owned(), + }, + )?; + + let dropped: Vec = paths.into_iter().map(Into::into).collect(); + let library: Vec = folders.into_iter().map(|(_, path)| path).collect(); + let copy_app = app.clone(); + let copy_managed = managed.clone(); + let report = tokio::task::spawn_blocking(move || { + // Announced before the walk, which can take a while on a large + // folder: with no row the status bar showed nothing, and nothing + // could stop it. The registry's own flag stops the walk and the + // copy, as it does a scan; the total is known once the walk is done. + let task = crate::tasks::start( + ©_app, + crate::tasks::TaskKind::DropImport, + 0, + crate::tasks::Cancellation::Flag, + ); + let should_stop = || task.as_ref().is_some_and(|t| t.is_cancelling()); + let plan = drop_import::plan(&dropped, ©_managed, &library, &should_stop); + let mut out = DropImportSummary { + in_library: plan.in_library, + refused: plan.refused, + errors: plan.read_errors, + truncated: plan.truncated, + cancelled: plan.stopped, + ..Default::default() }; - let canonical = folder.to_string_lossy().to_string(); - if seen.insert(canonical.clone()) { - folder_paths.push(canonical); + if out.cancelled || plan.copies.is_empty() { + return out; } - } - - let mut total = ScanSummary::default(); - for path in folder_paths { - let folder_id = repo.insert_or_get_folder(library_id, &path).await?; - - match crate::commands::scan::scan_folder_inner( - &pool, - &artwork_dir, - folder_id, - Some(&app), - false, - ) - .await - { - Ok(summary) => { - total.scanned += summary.scanned; - total.added += summary.added; - total.updated += summary.updated; - total.skipped += summary.skipped; - total.errors += summary.errors; - total.removed += summary.removed; - // Stopping one folder's scan stops the import (#601). - // Each folder registers its own task, so cancelling the - // one on screen leaves the next folder's flag clear and - // it would start immediately -- the user would press - // stop and watch the import carry on into a folder that - // had not begun. Reported too, so the caller's summary - // does not read as a completed import. - if summary.cancelled { - total.cancelled = true; - tracing::info!("import stopped by the user between folders"); - break; - } + let total = plan.copies.len() as u64; + for (done, copy) in plan.copies.iter().enumerate() { + if should_stop() { + out.cancelled = true; + break; } - Err(err) => { - tracing::warn!(folder_id, path = %path, %err, "import_paths: scan failed"); - total.errors += 1; + if let Some(task) = &task { + task.progress(done as u64, total); + if let Some(name) = copy.from.file_name() { + task.detail(name.to_string_lossy()); + } } + drop_import::apply(copy, &mut out); } + out + }) + .await + .map_err(|err| AppError::Other(format!("drop import: {err}")))?; + let folder = std::mem::take(&mut summary.folder); + summary = DropImportSummary { folder, ..report }; + + // Nothing in the managed folder, nothing to index. Files already there + // are scanned too: 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. A stopped copy still scans: what it + // wrote is complete, and leaving it unindexed would hide files the user + // can see in the folder. + if summary.copied == 0 && summary.already_copied == 0 { + return Ok(summary); } + let folder_id = match indexing { + Some(id) => id, + None => { + repo.insert_or_get_folder(library_id, &summary.folder) + .await? + } + }; + let scan = scan_folder_inner(&pool, &artwork_dir, folder_id, Some(&app), false).await?; + let fresh = scan.added > 0 && !scan.cancelled; + summary.scan = Some(scan); repo.touch_updated_at(library_id, now_millis()).await?; - - // Phase 4.d.0.3: wake the sync drain so the freshly-imported - // tracks ship without waiting on the drain's idle poll. + // Wake the sync drain so the new tracks ship without waiting on its + // idle poll. state.drain.notify(); - - // Not after an import the user stopped. Some tracks did land, so - // `added` is positive -- but answering a stop by starting the - // library-wide analysis sweep is the opposite of what was asked, - // and that sweep is the longest job in the app. - if total.added > 0 && !total.cancelled { + // Not after a stop: answering one with the library-wide analysis + // sweep, the longest job in the app, is the opposite of what was asked. + if fresh && !summary.cancelled { crate::commands::analysis::maybe_auto_analyze(&app); } - Ok(total) + Ok(summary) } /// Remove a folder from a library. Detaches the in-memory watcher, diff --git a/src-tauri/crates/app/src/commands/scan.rs b/src-tauri/crates/app/src/commands/scan.rs index 9947619e1..c9b211ee8 100644 --- a/src-tauri/crates/app/src/commands/scan.rs +++ b/src-tauri/crates/app/src/commands/scan.rs @@ -38,7 +38,7 @@ pub use waveflow_core::scanner::canonical_name; /// Process-wide count of in-flight library scans. Bumped for the whole /// body of [`scan_folder_inner`] — which every scan entry point routes -/// through (`scan_folder`, `rescan_library`, `import_paths`, the +/// through (`scan_folder`, `rescan_library`, `import_dropped`, the /// fs-watcher, the startup rescan in `lib.rs`). /// /// The background library analyzer reads this via [`scan_in_flight`] diff --git a/src-tauri/crates/app/src/drop_import.rs b/src-tauri/crates/app/src/drop_import.rs new file mode 100644 index 000000000..13a3a7c6b --- /dev/null +++ b/src-tauri/crates/app/src/drop_import.rs @@ -0,0 +1,549 @@ +//! Adding files dropped on the window to the library. +//! +//! A drop is a handful of files or an album folder, not a place to watch: +//! registering where they lie made the folder they came from a library +//! folder, and dropping one file from `Music` added the whole of `Music`, +//! thousands of tracks the user never asked for. So the dropped files are +//! **copied** into one folder WaveFlow manages, `WaveFlow` under the user's +//! music folder, 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 +//! the managed folder; a dropped file goes to its root. Nothing is +//! renamed from its tags. A folder brings its audio files and what the +//! app reads beside them (cover images, lyrics); a file brings its +//! same-name lyrics. +//! - **Lyrics follow their audio.** A `.lrc` or `.ttml` beside an audio file +//! of the same name is copied under the name the audio file ends up +//! with, so a numbered copy keeps its lyrics. +//! - **Already in the library is passed over.** An item under a library +//! folder, the managed one 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 the managed +//! one would copy it into itself. +//! - **A file already copied is not copied again.** One at the target with +//! the same name and size is taken as this file; one with the same name +//! and another size gets a numbered name. +//! - A copy is written under a temporary name and renamed when complete, +//! so neither the watcher nor a scan ever indexes half a file. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::Serialize; +use waveflow_core::scanner::extract::FOLDER_COVER_EXTENSIONS; +use waveflow_core::scanner::folders::{overlap, FolderRelation}; + +use crate::commands::scan::ScanSummary; + +/// The managed folder's name under the user's music folder. +pub const MANAGED_FOLDER_NAME: &str = "WaveFlow"; + +/// Lyrics sidecars, copied beside the audio file of the same name. Cover +/// images go too, by the scanner's own list ([`FOLDER_COVER_EXTENSIONS`]), +/// so the two cannot drift. +const LYRICS_EXTENSIONS: &[&str] = &["lrc", "ttml"]; + +/// Most files one drop copies. Past it the drop is cut short and says so. +const MAX_FILES: usize = 20_000; + +/// Most entries one drop walks, as [`crate::external_playback::expand`] +/// bounds its own: a drive dropped by mistake is not walked to the end. +const MAX_VISITED: usize = 200_000; + +/// How often the walk asks whether to stop, in entries. +const STOP_CHECK_EVERY: usize = 256; + +/// What a drop did, for the summary the frontend shows. +#[derive(Debug, Default, Serialize)] +pub struct DropImportSummary { + /// The managed folder the files were copied into. + pub folder: String, + /// Files copied. + pub copied: u32, + /// Files already in the managed folder from an earlier drop. + pub already_copied: u32, + /// Dropped items already under a library folder, passed over. + pub in_library: u32, + /// Dropped folders holding a library folder, refused. + pub refused: u32, + /// Entries that could not be read, files that could not be copied. + pub errors: u32, + /// The drop held more than [`MAX_FILES`] files; the rest were left. + pub truncated: bool, + /// The user stopped the copy. + pub cancelled: bool, + /// The managed folder's scan, when anything was copied. + pub scan: Option, +} + +/// `WaveFlow` under the user's music folder (`~/Music` where the platform +/// names none). +pub fn managed_folder() -> Option { + dirs::audio_dir() + .or_else(|| dirs::home_dir().map(|home| home.join("Music"))) + .map(|music| music.join(MANAGED_FOLDER_NAME)) +} + +/// One file to copy, where, and the lyrics that follow it. +#[derive(Debug, PartialEq, Eq)] +pub struct Copy { + pub from: PathBuf, + pub to: PathBuf, + /// Lyrics sidecars of an audio file, copied beside wherever it lands. + pub lyrics: Vec, +} + +/// The copies a drop makes, and what it passes over. +#[derive(Debug, Default)] +pub struct Plan { + pub copies: Vec, + pub in_library: u32, + pub refused: u32, + /// Entries the walk could not read: a folder it was refused, a file + /// gone in the meantime. Reported, so a partial import does not read as + /// a complete one. + pub read_errors: u32, + pub truncated: bool, + /// `should_stop` said so before the walk was done. + pub stopped: bool, +} + +/// What to copy for `dropped` into `managed`, given the library folders +/// (the managed folder counts as one). Blocking: walks dropped folders, +/// asking `should_stop` as it goes. +pub fn plan( + dropped: &[PathBuf], + managed: &Path, + library: &[PathBuf], + should_stop: &dyn Fn() -> bool, +) -> Plan { + let mut out = Plan::default(); + let mut visited = 0usize; + let folders = || library.iter().map(PathBuf::as_path).chain([managed]); + for item in dropped { + if should_stop() { + out.stopped = true; + return out; + } + match overlap(item, folders()) { + Some((FolderRelation::Same | FolderRelation::Inside, _)) => { + out.in_library += 1; + continue; + } + Some((FolderRelation::Contains, _)) => { + out.refused += 1; + continue; + } + None => {} + } + if item.is_dir() { + if !plan_folder(item, managed, &mut out, &mut visited, should_stop) { + return out; + } + } else if item.is_file() && is_audio(item) { + if out.copies.len() >= MAX_FILES { + out.truncated = true; + return out; + } + let Some(name) = item.file_name() else { + continue; + }; + out.copies.push(Copy { + from: item.clone(), + to: managed.join(name), + lyrics: LYRICS_EXTENSIONS + .iter() + .map(|ext| item.with_extension(ext)) + .filter(|lyrics| lyrics.is_file()) + .collect(), + }); + } + } + out +} + +/// Add a dropped folder's files to `out`. `false` when the walk stopped +/// early (a bound, or `should_stop`), which ends the plan. +fn plan_folder( + folder: &Path, + managed: &Path, + out: &mut Plan, + visited: &mut usize, + should_stop: &dyn Fn() -> bool, +) -> bool { + let base = managed.join(folder.file_name().unwrap_or_else(|| "Import".as_ref())); + let walk = walkdir::WalkDir::new(folder) + .sort_by(|a, b| a.file_name().cmp(b.file_name())) + .into_iter() + .filter_entry(|entry| { + entry.depth() == 0 + || !entry.file_type().is_dir() + || !waveflow_core::scanner::is_reserved_dir_name(entry.file_name()) + }); + // Each audio file by its folder and lower-cased stem, so the lyrics + // found after it in the walk can join it. + let mut audio_at: HashMap<(PathBuf, String), usize> = HashMap::new(); + let mut lyrics: Vec = Vec::new(); + for entry in walk { + *visited += 1; + if *visited % STOP_CHECK_EVERY == 0 && should_stop() { + out.stopped = true; + return false; + } + if *visited >= MAX_VISITED || out.copies.len() >= MAX_FILES { + out.truncated = true; + return false; + } + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + tracing::warn!(%err, "drop import: could not read an entry"); + out.read_errors += 1; + continue; + } + }; + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + if has_extension(path, LYRICS_EXTENSIONS) { + lyrics.push(entry.into_path()); + continue; + } + let audio = is_audio(path); + if !audio && !has_extension(path, FOLDER_COVER_EXTENSIONS) { + continue; + } + let Ok(rel) = path.strip_prefix(folder) else { + continue; + }; + if audio { + if let Some(key) = stem_key(path) { + audio_at.insert(key, out.copies.len()); + } + } + out.copies.push(Copy { + to: base.join(rel), + from: entry.into_path(), + lyrics: Vec::new(), + }); + } + // Lyrics beside an audio file of the same name follow it; any other is + // copied where it was. + for path in lyrics { + match stem_key(&path).and_then(|key| audio_at.get(&key)) { + Some(&index) => out.copies[index].lyrics.push(path), + None => { + let Ok(rel) = path.strip_prefix(folder) else { + continue; + }; + out.copies.push(Copy { + to: base.join(rel), + from: path, + lyrics: Vec::new(), + }); + } + } + } + true +} + +/// A file's folder and lower-cased stem: the key that pairs lyrics with +/// their audio file. +fn stem_key(path: &Path) -> Option<(PathBuf, String)> { + let parent = path.parent()?.to_path_buf(); + let stem = path.file_stem()?.to_string_lossy().to_lowercase(); + Some((parent, stem)) +} + +fn is_audio(path: &Path) -> bool { + waveflow_core::scanner::extract::is_scannable_audio(path) +} + +fn has_extension(path: &Path, extensions: &[&str]) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| extensions.iter().any(|e| ext.eq_ignore_ascii_case(e))) +} + +/// Held across one planned copy, from choosing its target name to the +/// rename that completes it. Two drops running at once would otherwise both +/// find a name free, write the same `.waveflow-part`, and the second rename +/// would replace the first file. Copies are one at a time within a drop +/// anyway, so this only makes two drops take turns, file by file. +static COPY_LOCK: Mutex<()> = Mutex::new(()); + +/// Make one planned copy, its lyrics after it, and count the outcome. +pub fn apply(copy: &Copy, summary: &mut DropImportSummary) { + // A copy that panicked holding the lock left nothing half-reserved: its + // target is either renamed in place or still a `.waveflow-part`. So the + // lock is taken back rather than the import abandoned. + let _turn = COPY_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let landed = match copy_one(©.from, ©.to) { + Ok(Copied::New(at)) => { + summary.copied += 1; + at + } + Ok(Copied::AlreadyThere(at)) => { + summary.already_copied += 1; + at + } + Err(err) => { + tracing::warn!(from = %copy.from.display(), %err, "drop import: copy failed"); + summary.errors += 1; + return; + } + }; + for lyrics in ©.lyrics { + let Some(ext) = lyrics.extension() else { + continue; + }; + match copy_beside(lyrics, &landed.with_extension(ext)) { + Ok(Copied::New(_)) => summary.copied += 1, + Ok(Copied::AlreadyThere(_)) => summary.already_copied += 1, + Err(err) => { + tracing::warn!(from = %lyrics.display(), %err, "drop import: copy failed"); + summary.errors += 1; + } + } + } +} + +/// What one copy did. +#[derive(Debug, PartialEq, Eq)] +pub enum Copied { + /// Written, at this path (a numbered name when the first was taken). + New(PathBuf), + /// A file of the same name and size was already there, at this path. + AlreadyThere(PathBuf), +} + +/// Copy `from` to `to`, or to `to` numbered when another file holds that +/// name, through a temporary name renamed when complete. +pub fn copy_one(from: &Path, to: &Path) -> std::io::Result { + let size = std::fs::metadata(from)?.len(); + let mut target = to.to_path_buf(); + let mut n = 1u32; + loop { + match std::fs::metadata(&target) { + Ok(meta) if meta.is_file() && meta.len() == size => { + return Ok(Copied::AlreadyThere(target)) + } + Ok(_) => { + n += 1; + if n > 99 { + return Err(std::io::Error::other("no free name at the target")); + } + target = numbered(to, n); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => break, + Err(err) => return Err(err), + } + } + write_through_part(from, &target)?; + Ok(Copied::New(target)) +} + +/// Copy a sidecar to exactly `to`, beside the file it belongs to. One +/// already there is kept, whatever it holds: it may be lyrics the user +/// edited, and a numbered copy would belong to no audio file. +fn copy_beside(from: &Path, to: &Path) -> std::io::Result { + if to.exists() { + return Ok(Copied::AlreadyThere(to.to_path_buf())); + } + write_through_part(from, to)?; + Ok(Copied::New(to.to_path_buf())) +} + +/// Write `from` to `target` under a temporary name, renamed when complete. +fn write_through_part(from: &Path, target: &Path) -> std::io::Result<()> { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let mut part_name = target.file_name().unwrap_or_default().to_os_string(); + part_name.push(".waveflow-part"); + let part = target.with_file_name(part_name); + if let Err(err) = std::fs::copy(from, &part).and_then(|_| std::fs::rename(&part, target)) { + let _ = std::fs::remove_file(&part); + return Err(err); + } + Ok(()) +} + +/// `name (n).ext` beside `path`. +fn numbered(path: &Path, n: u32) -> PathBuf { + let stem = path.file_stem().unwrap_or_default().to_string_lossy(); + let name = match path.extension() { + Some(ext) => format!("{stem} ({n}).{}", ext.to_string_lossy()), + None => format!("{stem} ({n})"), + }; + path.with_file_name(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn touch(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + fn never() -> bool { + false + } + + #[test] + fn a_folder_keeps_its_tree_and_lyrics_join_their_audio() { + let dir = tempfile::tempdir().unwrap(); + let album = dir.path().join("Downloads").join("Album"); + touch(&album.join("01.flac"), b"a"); + touch(&album.join("CD2").join("01.mp3"), b"b"); + touch(&album.join("cover.jpg"), b"c"); + touch(&album.join("01.LRC"), b"d"); + touch(&album.join("booklet.lrc"), b"g"); + touch(&album.join("notes.pdf"), b"e"); + touch(&album.join(".waveflow").join("clip.mp4"), b"f"); + let managed = dir.path().join("Music").join("WaveFlow"); + + let plan = plan(std::slice::from_ref(&album), &managed, &[], &never); + let base = managed.join("Album"); + let mut to: Vec<_> = plan.copies.iter().map(|c| c.to.clone()).collect(); + to.sort(); + assert_eq!( + to, + vec![ + base.join("01.flac"), + base.join("CD2").join("01.mp3"), + base.join("booklet.lrc"), + base.join("cover.jpg"), + ] + ); + let flac = plan + .copies + .iter() + .find(|c| c.to == base.join("01.flac")) + .unwrap(); + assert_eq!(flac.lyrics, vec![album.join("01.LRC")]); + assert_eq!(plan.read_errors, 0); + } + + #[test] + fn a_file_goes_to_the_root_with_its_lyrics() { + let dir = tempfile::tempdir().unwrap(); + let song = dir.path().join("Downloads").join("song.mp3"); + touch(&song, b"a"); + touch(&song.with_extension("lrc"), b"b"); + touch(&dir.path().join("Downloads").join("other.mp3"), b"c"); + let managed = dir.path().join("Music").join("WaveFlow"); + + let plan = plan(std::slice::from_ref(&song), &managed, &[], &never); + assert_eq!( + plan.copies, + vec![Copy { + from: song.clone(), + to: managed.join("song.mp3"), + lyrics: vec![song.with_extension("lrc")], + }] + ); + } + + #[test] + fn library_items_are_passed_over_and_folders_holding_one_refused() { + let dir = tempfile::tempdir().unwrap(); + let music = dir.path().join("Music"); + let library = music.join("Albums"); + touch(&library.join("Album").join("01.flac"), b"a"); + touch(&music.join("Loose").join("02.flac"), b"b"); + let managed = music.join("WaveFlow"); + + // The library's own album, the music folder that holds both the + // library folder and the managed one, and a file already copied. + touch(&managed.join("03.flac"), b"c"); + let dropped = [ + library.join("Album"), + music.clone(), + managed.join("03.flac"), + ]; + let plan = plan(&dropped, &managed, std::slice::from_ref(&library), &never); + assert!(plan.copies.is_empty()); + assert_eq!(plan.in_library, 2); + assert_eq!(plan.refused, 1); + } + + #[test] + fn a_stop_ends_the_plan() { + let dir = tempfile::tempdir().unwrap(); + let song = dir.path().join("song.mp3"); + touch(&song, b"a"); + let plan = plan(&[song], &dir.path().join("WaveFlow"), &[], &|| true); + assert!(plan.stopped); + assert!(plan.copies.is_empty()); + } + + #[test] + fn a_copy_is_not_repeated_and_a_different_file_gets_a_number() { + let dir = tempfile::tempdir().unwrap(); + let from = dir.path().join("in").join("song.mp3"); + touch(&from, b"abc"); + let to = dir.path().join("out").join("song.mp3"); + + assert_eq!(copy_one(&from, &to).unwrap(), Copied::New(to.clone())); + assert_eq!( + copy_one(&from, &to).unwrap(), + Copied::AlreadyThere(to.clone()) + ); + + let other = dir.path().join("in2").join("song.mp3"); + touch(&other, b"abcd"); + let numbered = dir.path().join("out").join("song (2).mp3"); + assert_eq!( + copy_one(&other, &to).unwrap(), + Copied::New(numbered.clone()) + ); + assert_eq!(std::fs::read(numbered).unwrap(), b"abcd"); + // Nothing half-written is left behind. + let leftovers: Vec<_> = std::fs::read_dir(dir.path().join("out")) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().ends_with(".waveflow-part")) + .collect(); + assert!(leftovers.is_empty()); + } + + #[test] + fn lyrics_take_the_name_their_numbered_audio_got() { + let dir = tempfile::tempdir().unwrap(); + let managed = dir.path().join("WaveFlow"); + // Another song.mp3 already in the managed folder, with its lyrics. + touch(&managed.join("song.mp3"), b"other"); + touch(&managed.join("song.lrc"), b"other lyrics"); + let song = dir.path().join("in").join("song.mp3"); + touch(&song, b"mine"); + touch(&song.with_extension("lrc"), b"my lyrics"); + + let plan = plan(std::slice::from_ref(&song), &managed, &[], &never); + let mut summary = DropImportSummary::default(); + for copy in &plan.copies { + apply(copy, &mut summary); + } + assert_eq!(summary.copied, 2); + assert_eq!( + std::fs::read(managed.join("song (2).mp3")).unwrap(), + b"mine" + ); + assert_eq!( + std::fs::read(managed.join("song (2).lrc")).unwrap(), + b"my lyrics" + ); + // The other song's lyrics are untouched. + assert_eq!( + std::fs::read(managed.join("song.lrc")).unwrap(), + b"other lyrics" + ); + } +} diff --git a/src-tauri/crates/app/src/error.rs b/src-tauri/crates/app/src/error.rs index 202ce8ba3..9e68601bc 100644 --- a/src-tauri/crates/app/src/error.rs +++ b/src-tauri/crates/app/src/error.rs @@ -121,10 +121,34 @@ pub enum AppError { #[error("the file changed on disk since the editor opened it")] FileChangedOnDisk, + /// A folder that overlaps a library folder: the same one, one under + /// it, or one holding it ([`waveflow_core::scanner::folders`]). Both + /// would claim the files they share. The frontend recognises the + /// message (`parseFolderOverlap` in `src/lib/tauri/library.ts`) to + /// say which folder is in the way, so the wording is part of the + /// contract. + #[error( + "the folder {} the library folder {existing}", + overlap_phrase(relation) + )] + FolderOverlap { + relation: waveflow_core::scanner::folders::FolderRelation, + existing: String, + }, + #[error("{0}")] Other(String), } +fn overlap_phrase(relation: &waveflow_core::scanner::folders::FolderRelation) -> &'static str { + use waveflow_core::scanner::folders::FolderRelation; + match relation { + FolderRelation::Same => "is", + FolderRelation::Inside => "is inside", + FolderRelation::Contains => "contains", + } +} + impl Serialize for AppError { fn serialize(&self, serializer: S) -> Result where diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index d6f65a2db..db3e20680 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -33,6 +33,7 @@ mod render_mode; // unconditionally so the always-present player control seams can probe // and clear it; the `sync_v2`-gated orchestration lives in // `remote::playback`. +mod drop_import; mod external_playback; mod launch_args; mod remote_playback; @@ -844,7 +845,7 @@ pub fn run() { commands::library::set_folder_watched, commands::library::remove_folder_from_library, commands::library::count_folder_play_events, - commands::library::import_paths, + commands::library::import_dropped, commands::duplicates::find_duplicates, commands::duplicates::move_tracks_to_trash, commands::replace::check_track_replacement, diff --git a/src-tauri/crates/app/src/tasks.rs b/src-tauri/crates/app/src/tasks.rs index 63cc08c92..729968cdc 100644 --- a/src-tauri/crates/app/src/tasks.rs +++ b/src-tauri/crates/app/src/tasks.rs @@ -74,6 +74,9 @@ pub enum TaskKind { Upload, Backup, Thumbnails, + /// Files dropped on the window, copied into the folder WaveFlow + /// manages under the user's music ([`crate::drop_import`]). + DropImport, } impl TaskKind { @@ -87,6 +90,7 @@ impl TaskKind { Self::Upload => "upload", Self::Backup => "backup", Self::Thumbnails => "thumbnails", + Self::DropImport => "drop_import", } } } @@ -493,12 +497,13 @@ mod tests { TaskKind::Upload, TaskKind::Backup, TaskKind::Thumbnails, + TaskKind::DropImport, ]; let mut seen = std::collections::HashSet::new(); for kind in all { assert!(seen.insert(kind.slug()), "duplicate slug {}", kind.slug()); } - assert_eq!(seen.len(), 8); + assert_eq!(seen.len(), 9); assert_eq!(TaskKind::LibraryScan.slug(), "library_scan"); assert_eq!(TaskKind::CatalogueMirror.slug(), "catalogue_mirror"); } diff --git a/src-tauri/crates/core/src/scanner/extract.rs b/src-tauri/crates/core/src/scanner/extract.rs index 43febf2ec..fc5eaeef8 100644 --- a/src-tauri/crates/core/src/scanner/extract.rs +++ b/src-tauri/crates/core/src/scanner/extract.rs @@ -408,7 +408,7 @@ const FOLDER_COVER_STEMS: &[&str] = &["cover", "folder", "front", "albumart", "a /// the `image` crate decodes via the features enabled in `Cargo.toml`, so /// every match downstream of this fn is guaranteed to be readable by the /// thumbnail pipeline. -const FOLDER_COVER_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "gif", "tiff"]; +pub const FOLDER_COVER_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "gif", "tiff"]; /// Look for a sidecar cover image (cover.jpg / folder.png / front.webp / ...) /// next to the track. Returns an `ExtractedCover` written to the shared diff --git a/src-tauri/crates/core/src/scanner/folders.rs b/src-tauri/crates/core/src/scanner/folders.rs new file mode 100644 index 000000000..13a63142b --- /dev/null +++ b/src-tauri/crates/core/src/scanner/folders.rs @@ -0,0 +1,238 @@ +//! How two library folders sit relative to each other. +//! +//! A library folder owns the tracks its scan finds, and removing it deletes +//! them (`track.folder_id`). Two folders where one holds the other would +//! both claim the files they share: each scan reads them again, the last +//! one to run takes them, and removing either can take tracks the other +//! still shows. So a folder that overlaps one already there is refused, +//! and the drop import copies nothing out of a library folder. +//! +//! The comparison is by name, as the library stores paths: `.` and `..` are +//! resolved, a trailing separator means nothing, and on the platforms whose +//! file systems ignore case by default (Windows, macOS) so does this. Links +//! are not followed, which `canonicalize` would do, adding a `\\?\` prefix +//! on Windows that no stored path has. + +use std::path::{Component, Path, PathBuf}; + +/// Where a candidate folder lies relative to a library folder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FolderRelation { + /// The same folder. + Same, + /// Under the library folder. + Inside, + /// Above the library folder: the library folder is under it. + Contains, +} + +/// How `candidate` lies relative to `folder`, or `None` when neither holds +/// the other. +pub fn relation(candidate: &Path, folder: &Path) -> Option { + let candidate = parts(candidate); + let folder = parts(folder); + if candidate.is_empty() || folder.is_empty() { + return None; + } + if candidate == folder { + Some(FolderRelation::Same) + } else if candidate.starts_with(&folder) { + Some(FolderRelation::Inside) + } else if folder.starts_with(&candidate) { + Some(FolderRelation::Contains) + } else { + None + } +} + +/// The library folder `candidate` overlaps, if any, and how. When it meets +/// several, the same folder wins, then one holding it, then one under it: +/// the first two say the candidate is already covered, which is the more +/// useful thing to tell. +pub fn overlap<'a>( + candidate: &Path, + folders: impl IntoIterator, +) -> Option<(FolderRelation, PathBuf)> { + let rank = |r: FolderRelation| match r { + FolderRelation::Same => 0, + FolderRelation::Inside => 1, + FolderRelation::Contains => 2, + }; + let mut found: Option<(FolderRelation, PathBuf)> = None; + for folder in folders { + let Some(rel) = relation(candidate, folder) else { + continue; + }; + let closer = match &found { + Some((kept, _)) => rank(rel) < rank(*kept), + None => true, + }; + if closer { + found = Some((rel, folder.to_path_buf())); + } + } + found +} + +/// The library folder that indexes `managed`'s files: its own row, else the +/// deepest library folder holding it (a library that is the whole music +/// folder already scans it). `None` when neither exists and `managed` must +/// be added. +/// +/// A library folder *inside* `managed` is an overlap this cannot resolve, +/// and is returned as one. +pub fn indexing_folder( + managed: &Path, + folders: &[(i64, PathBuf)], +) -> Result, (FolderRelation, PathBuf)> { + let mut best: Option<(i64, usize)> = None; + for (id, path) in folders { + match relation(managed, path) { + Some(FolderRelation::Same) => return Ok(Some(*id)), + Some(FolderRelation::Inside) => { + let depth = parts(path).len(); + // Spelled without `Option::is_none_or`: the MSRV is 1.80. + let deeper = match best { + Some((_, deepest)) => depth > deepest, + None => true, + }; + if deeper { + best = Some((*id, depth)); + } + } + Some(FolderRelation::Contains) => { + return Err((FolderRelation::Contains, path.clone())); + } + None => {} + } + } + Ok(best.map(|(id, _)| id)) +} + +/// The path's components by name, `.` and `..` resolved, folded to lower +/// case where the file system ignores it. +fn parts(path: &Path) -> Vec { + let mut out: Vec = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => { + let name = other.as_os_str().to_string_lossy(); + out.push(if cfg!(any(windows, target_os = "macos")) { + name.to_lowercase() + } else { + name.into_owned() + }); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(s: &str) -> PathBuf { + PathBuf::from(s) + } + + #[test] + fn same_inside_contains_and_apart() { + let music = p("/home/u/Music"); + assert_eq!(relation(&music, &music), Some(FolderRelation::Same)); + assert_eq!( + relation(&p("/home/u/Music/Albums"), &music), + Some(FolderRelation::Inside) + ); + assert_eq!( + relation(&music, &p("/home/u/Music/Albums")), + Some(FolderRelation::Contains) + ); + assert_eq!(relation(&p("/home/u/Downloads"), &music), None); + } + + #[test] + fn a_shared_prefix_of_a_name_is_not_containment() { + // `Music2` starts with the characters of `Music` but is not under it. + assert_eq!(relation(&p("/home/u/Music2"), &p("/home/u/Music")), None); + } + + #[test] + fn dots_and_a_trailing_separator_change_nothing() { + let music = p("/home/u/Music"); + assert_eq!( + relation(&p("/home/u/Music/"), &music), + Some(FolderRelation::Same) + ); + assert_eq!( + relation(&p("/home/u/Downloads/../Music/./Albums"), &music), + Some(FolderRelation::Inside) + ); + } + + #[test] + fn overlap_prefers_same_then_inside_then_contains() { + let candidate = p("/m/Music/Albums"); + let under = p("/m/Music/Albums/Live"); + let above = p("/m/Music"); + let same = p("/m/Music/Albums"); + let apart = p("/m/Downloads"); + let pick = |folders: &[&PathBuf]| overlap(&candidate, folders.iter().map(|f| f.as_path())); + + assert_eq!(pick(&[&apart]), None); + assert_eq!( + pick(&[&under]), + Some((FolderRelation::Contains, under.clone())) + ); + assert_eq!( + pick(&[&under, &above]), + Some((FolderRelation::Inside, above.clone())) + ); + assert_eq!( + pick(&[&under, &above, &same, &apart]), + Some((FolderRelation::Same, same.clone())) + ); + } + + #[test] + fn the_managed_folder_is_indexed_by_its_row_or_the_folder_holding_it() { + let managed = p("/m/Music/WaveFlow"); + let music = (1, p("/m/Music")); + let deeper = (5, p("/m/Music/")); + let own = (2, managed.clone()); + let other = (3, p("/m/Downloads")); + assert_eq!( + indexing_folder(&managed, std::slice::from_ref(&other)), + Ok(None) + ); + assert_eq!( + indexing_folder(&managed, &[music.clone(), other]), + Ok(Some(1)) + ); + assert_eq!( + indexing_folder(&managed, &[music.clone(), own]), + Ok(Some(2)) + ); + // A trailing separator is not a deeper folder. + assert_eq!(indexing_folder(&managed, &[music, deeper]), Ok(Some(1))); + let under = (4, managed.join("Album")); + assert_eq!( + indexing_folder(&managed, std::slice::from_ref(&under)), + Err((FolderRelation::Contains, under.1)) + ); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_separator_style() { + assert_eq!( + relation(&p(r"c:\users\u\music\Albums"), &p("C:/Users/U/Music")), + Some(FolderRelation::Inside) + ); + } +} diff --git a/src-tauri/crates/core/src/scanner/mod.rs b/src-tauri/crates/core/src/scanner/mod.rs index 079a2c9dd..08e513178 100644 --- a/src-tauri/crates/core/src/scanner/mod.rs +++ b/src-tauri/crates/core/src/scanner/mod.rs @@ -12,6 +12,7 @@ pub mod canonical; pub mod extra_tags; pub mod extract; +pub mod folders; pub mod replay_gain; pub mod reserved; diff --git a/src/components/common/ImportNoticeToast.tsx b/src/components/common/ImportNoticeToast.tsx new file mode 100644 index 000000000..4ef8a8795 --- /dev/null +++ b/src/components/common/ImportNoticeToast.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, CheckCircle2, X } from "lucide-react"; + +import { + IMPORT_NOTICE_EVENT, + type ImportNotice, +} from "../../lib/tauri/library"; + +/** Long enough to read a few lines, for a notice that needs nothing. */ +const AUTO_HIDE_MS = 6_000; + +interface Shown { + notice: ImportNotice; + /** Distinguishes one notice from the next, so a repeat re-shows. */ + key: number; +} + +/** + * What adding to the library did, where the scan's own card cannot say: + * a folder refused for overlapping one already in the library, and what a + * drop copied into the managed folder and what it passed over. Fed by + * `waveflow:import-notice` ({@link announceImport}), so the four places + * that add a folder and the drop are covered without each knowing. + * + * Bottom centre, apart from the scan's card at the bottom right: a drop + * shows both, the copy here and the scan's outcome there. A notice that + * needs looking at (a refusal, a failure, a cut) stays until dismissed. + */ +export function ImportNoticeToast() { + const { t } = useTranslation(); + const [shown, setShown] = useState(null); + const timer = useRef(null); + + useEffect(() => { + let next = 0; + const onNotice = (event: Event) => { + const notice = (event as CustomEvent).detail; + if (!notice) return; + next += 1; + setShown({ notice, key: next }); + if (timer.current != null) window.clearTimeout(timer.current); + timer.current = null; + if (!needsAttention(notice)) { + timer.current = window.setTimeout(() => setShown(null), AUTO_HIDE_MS); + } + }; + window.addEventListener(IMPORT_NOTICE_EVENT, onNotice); + return () => { + window.removeEventListener(IMPORT_NOTICE_EVENT, onNotice); + if (timer.current != null) window.clearTimeout(timer.current); + }; + }, []); + + if (!shown) return null; + const { notice } = shown; + const attention = needsAttention(notice); + + let title: string; + let lines: string[]; + let folder: string | null = null; + if (notice.kind === "overlap") { + title = t("importNotice.overlap.title"); + lines = [ + t(`importNotice.overlap.${notice.overlap.relation}`, { + folder: notice.overlap.folder, + }), + ]; + } else { + const s = notice.summary; + // Files dropped again after their folder left the library are not + // copied, but the scan puts them back: that is still an addition. + title = + s.copied > 0 || (s.scan?.added ?? 0) > 0 + ? t("importNotice.dropped.title") + : t("importNotice.dropped.titleNothing"); + folder = s.copied > 0 || s.already_copied > 0 ? s.folder : null; + lines = [ + s.copied > 0 && t("importNotice.dropped.copied", { n: s.copied }), + s.already_copied > 0 && + t("importNotice.dropped.alreadyCopied", { n: s.already_copied }), + s.in_library > 0 && + t("importNotice.dropped.inLibrary", { n: s.in_library }), + s.refused > 0 && t("importNotice.dropped.refused", { n: s.refused }), + s.errors > 0 && t("importNotice.dropped.errors", { n: s.errors }), + s.truncated && t("importNotice.dropped.truncated"), + s.cancelled && t("importNotice.dropped.cancelled"), + ].filter((line): line is string => typeof line === "string"); + } + + return ( +
+
+
+ {attention ? : } +
+
+
+ {title} +
+ {folder && ( +
+ {folder} +
+ )} + {lines.map((line) => ( +
+ {line} +
+ ))} +
+ +
+
+ ); +} + +/** A refusal, a failure, a cut or a stop: kept on screen until read. */ +function needsAttention(notice: ImportNotice): boolean { + if (notice.kind === "overlap") return true; + const s = notice.summary; + return ( + s.refused > 0 || + s.errors > 0 || + s.truncated || + s.cancelled || + (s.copied === 0 && s.already_copied === 0) + ); +} diff --git a/src/components/common/PlaybackAlertToast.tsx b/src/components/common/PlaybackAlertToast.tsx index 5b30f16c9..4a742b505 100644 --- a/src/components/common/PlaybackAlertToast.tsx +++ b/src/components/common/PlaybackAlertToast.tsx @@ -17,6 +17,9 @@ const ERROR_KINDS = new Set([ "decoder-crashed", "decoder-stopped", "offline", + // Raised by the frontend's own "play without importing" paths. + "files-failed", + "picker-failed", ]); const NOTICE_KINDS = new Set([ "exclusive-refused", diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index fc4e64d4e..82546e6c6 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -16,13 +16,12 @@ import { SkinAmbientBackdrop } from "./SkinAmbientBackdrop"; import { SkinPlayingState } from "./SkinPlayingState"; import { SkinMotionWrapper } from "./SkinMotionWrapper"; import { useDragDropImport } from "../../hooks/useDragDropImport"; +import { DropTargetsOverlay } from "./DropTargetsOverlay"; import { useGlobalShortcuts } from "../../hooks/useGlobalShortcuts"; import { useUiZoom } from "../../hooks/useUiZoom"; import { useWindowChrome } from "../../hooks/useWindowChrome"; import { AppTitleBar } from "./AppTitleBar"; import { useMainWindowBounds } from "../../hooks/useMainWindowBounds"; -import { useTranslation } from "react-i18next"; -import { Loader2, Upload } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import { TopBar } from "./TopBar"; import { QueuePanel } from "./QueuePanel"; @@ -39,6 +38,7 @@ import { SoftwareRenderingBanner } from "../common/SoftwareRenderingBanner"; import { SkinRecoveryBanner } from "../common/SkinRecoveryBanner"; import { UpdateBanner } from "../common/UpdateBanner"; import { ScanProgressToast } from "../common/ScanProgressToast"; +import { ImportNoticeToast } from "../common/ImportNoticeToast"; import { TaskStatusBar } from "./TaskStatusBar"; import { PlaybackAlertToast } from "../common/PlaybackAlertToast"; import { QueueToast } from "../common/QueueToast"; @@ -180,7 +180,6 @@ type HistoryEntry = }; export function AppLayout() { - const { t } = useTranslation(); const { activeRightPanel } = usePlayer(); // The Fluid skin shows the three right-edge panels as the tabs of // one shell; every other skin mounts them one at a time, as before. @@ -755,33 +754,14 @@ export function AppLayout() { {windowChrome.draw === "overlay" && (
)} - {/* Drag-and-drop overlay — fades in while the user is dragging - files over the window, and shows an "importing…" state while - the backend scan runs. Pointer-events disabled so the drop - still hits Tauri's native handler underneath. */} - {(dragDrop.isDraggingOver || dragDrop.isImporting) && ( -
-
- {dragDrop.isImporting ? ( - - ) : ( - - )} -
-
- {dragDrop.isImporting - ? t("dragDrop.importing") - : t("dragDrop.dropHint")} -
-
- {t("dragDrop.subtitle")} -
-
-
-
+ {/* Drag-and-drop: two targets while files are dragged over the + window — play them now without importing them, or add them to + the library. An import's scan then reports in the status bar. */} + {dragDrop.isDraggingOver && ( + )} {/* Main Container */} @@ -944,6 +924,7 @@ export function AppLayout() { + diff --git a/src/components/layout/DropTargetsOverlay.tsx b/src/components/layout/DropTargetsOverlay.tsx new file mode 100644 index 000000000..f2933bf52 --- /dev/null +++ b/src/components/layout/DropTargetsOverlay.tsx @@ -0,0 +1,116 @@ +import type { ReactNode, RefObject } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { FolderPlus, Play } from "lucide-react"; +import type { + DropTarget, + DropTargetElements, +} from "../../hooks/useDragDropImport"; + +/** + * What a drop on the window can do, shown while files are dragged over + * it: play them now without importing them, or add them to the library. + * The pointer picks the target (`useDragDropImport` follows it across the + * boxes registered in `targetsRef`); the one under it lights up. + * + * Portalled to `document.body`, above every skin's stacking contexts. + * Pointer-events are off so the drop still reaches Tauri's native handler + * underneath. It goes away on the drop: an import's scan reports in the + * task status bar like any other, where it can be followed and stopped, + * rather than behind a screen that hides the app for as long as it runs. + * + * Base styling works in every skin; the fluid layout recomposes it through + * the `data-drop-*` landmarks. + */ +export function DropTargetsOverlay({ + hoveredTarget, + targetsRef, +}: { + hoveredTarget: DropTarget | null; + targetsRef: RefObject; +}) { + const { t } = useTranslation(); + + return createPortal( +
+ } + title={t("dragDrop.play.title")} + hint={t("dragDrop.play.hint")} + targetsRef={targetsRef} + /> + } + title={t("dragDrop.import.title")} + hint={t("dragDrop.import.hint")} + targetsRef={targetsRef} + /> +
, + document.body, + ); +} + +function Target({ + target, + active, + icon, + title, + hint, + targetsRef, +}: { + target: DropTarget; + active: boolean; + icon: ReactNode; + title: string; + hint: string; + targetsRef: RefObject; +}) { + return ( +
{ + targetsRef.current[target] = el; + }} + data-drop-target={target} + data-active={active || undefined} + className="relative flex-1 flex flex-col items-center justify-center gap-3 rounded-2xl bg-white dark:bg-zinc-900 px-8 text-center shadow-2xl" + > + {/* The outline is its own layer: a skin that restyles the card as + one of its panels (border and all) still shows which target is + the one under the pointer. */} +
+
+ {icon} +
+
+ {title} +
+
+ {hint} +
+
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 82217b4c0..7a6e9116d 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -16,7 +16,17 @@ import { Sparkles, Radio, Puzzle, + PlayCircle, + FileAudio, + FolderOpen, } from "lucide-react"; +import { + ContextMenu, + ContextMenuItem, + type ContextMenuPoint, +} from "../common/ContextMenu"; +import { openFilesToPlay, openFolderToPlay } from "../../lib/openToPlay"; +import { loadBindings, type ShortcutBindings } from "../../lib/shortcuts"; import type { ViewId, LibraryTab } from "../../types"; import { NavItem } from "../common/NavItem"; import { WaveFlowLogo } from "../common/WaveFlowLogo"; @@ -179,6 +189,17 @@ export function Sidebar({ // the new profile's data immediately (not just after a track swap). }, [refreshStats, activeProfile]); + // "Play without importing": a small menu under its button, files or a + // folder. The same two actions are bound to Ctrl+O / Ctrl+Shift+O. + const [openMenuPoint, setOpenMenuPoint] = useState( + null, + ); + // The bindings as the user set them, read when the menu opens so a + // rebound shortcut is the one shown. + const [openBindings, setOpenBindings] = useState( + null, + ); + // Import a folder: auto-create a library if none exists, then scan. const handleImportFolder = async () => { if (isImporting) return; @@ -443,15 +464,35 @@ export function Sidebar({ className="flex items-center justify-between text-[10px] font-bold tracking-widest text-zinc-400 mb-1 px-2 uppercase" > {t("sidebar.myMusic.title")} - + + + +
)} setOpenMenuPoint(null)} + width={240} + > + } + label={t("openToPlay.files")} + shortcut={openBindings?.openFiles || undefined} + onSelect={() => { + setOpenMenuPoint(null); + void openFilesToPlay(); + }} + /> + } + label={t("openToPlay.folder")} + shortcut={openBindings?.openFolder || undefined} + onSelect={() => { + setOpenMenuPoint(null); + void openFolderToPlay(); + }} + /> + + )} + setIsCreatePlaylistModalOpen(false)} diff --git a/src/contexts/LibraryContext.tsx b/src/contexts/LibraryContext.tsx index 79eff627a..77464612b 100644 --- a/src/contexts/LibraryContext.tsx +++ b/src/contexts/LibraryContext.tsx @@ -11,9 +11,11 @@ import { LibraryContext } from "../hooks/useLibrary"; import { useProfile } from "../hooks/useProfile"; import { addFolderToLibrary, + announceImport, createLibrary as apiCreateLibrary, deleteLibrary as apiDeleteLibrary, listLibraries, + parseFolderOverlap, rescanLibrary as apiRescanLibrary, scanFolder, updateLibrary as apiUpdateLibrary, @@ -163,7 +165,16 @@ export function LibraryProvider({ children }: { children: ReactNode }) { const importFolder = useCallback( async (libraryId: number, path: string) => { - const folderId = await addFolderToLibrary(libraryId, path); + let folderId: number; + try { + folderId = await addFolderToLibrary(libraryId, path); + } catch (err) { + // Said here, once, rather than in each of the places that add a + // folder: they only log a failure. + const overlap = parseFolderOverlap(err); + if (overlap) announceImport({ kind: "overlap", overlap }); + throw err; + } const summary = await scanFolder(folderId); await refresh(); return summary; diff --git a/src/hooks/useDragDropImport.ts b/src/hooks/useDragDropImport.ts index 6641a3b88..612ec5006 100644 --- a/src/hooks/useDragDropImport.ts +++ b/src/hooks/useDragDropImport.ts @@ -1,29 +1,83 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { useLibrary } from "./useLibrary"; -import { importPaths } from "../lib/tauri/library"; +import { + announceImport, + importDropped, + parseFolderOverlap, +} from "../lib/tauri/library"; +import { playWithoutImporting } from "../lib/openToPlay"; /** Subset of `tauri-apps`' DragDropEvent we actually consume. */ type DragEventKind = "enter" | "over" | "drop" | "leave"; interface DragPayload { type: DragEventKind; paths?: string[]; + /** Physical pixels, from the webview's top-left corner. */ + position?: { x: number; y: number }; } +/** The two things a drop can do. */ +export type DropTarget = "play" | "import"; + +/** Where the two targets are on screen, filled in by the overlay. */ +export type DropTargetElements = Partial< + Record +>; + /** - * Wires Tauri's window-level drag-drop signal into the existing - * library-import flow. Drops route through `import_paths` which - * accepts a mix of folders and audio files (files contribute their - * parent directory). + * The target under `point` (CSS pixels), or the nearer one when the point + * is in neither — the gap between them, the overlay's margin. Read from the + * targets' own boxes, so a right-to-left layout that mirrors them is right + * without knowing it. + */ +function targetAt( + elements: DropTargetElements, + point: { x: number; y: number }, +): DropTarget | null { + let nearest: DropTarget | null = null; + let nearestDistance = Infinity; + for (const target of ["play", "import"] as const) { + const box = elements[target]?.getBoundingClientRect(); + if (!box) continue; + if ( + point.x >= box.left && + point.x <= box.right && + point.y >= box.top && + point.y <= box.bottom + ) { + return target; + } + const distance = Math.hypot( + point.x - (box.left + box.width / 2), + point.y - (box.top + box.height / 2), + ); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = target; + } + } + return nearest; +} + +/** + * Wires Tauri's window-level drag-drop signal to its two targets: play the + * files now without importing them, or add them to the library through + * `import_dropped`, which copies them into the folder WaveFlow manages + * under the user's music and scans it. What it did is announced for the + * import toast. * - * Returns the active drag state so the caller can render an - * overlay with a drop hint. + * The overlay registers where the targets are in `targetsRef`; the hook + * follows the pointer across them and reports the one under it, which is + * where a drop goes. */ export function useDragDropImport(): { isDraggingOver: boolean; - isImporting: boolean; lastError: string | null; + /** The target the pointer is over while dragging. */ + hoveredTarget: DropTarget | null; + targetsRef: React.RefObject; } { const { libraries, @@ -34,12 +88,12 @@ export function useDragDropImport(): { } = useLibrary(); const { t } = useTranslation(); const [isDraggingOver, setIsDraggingOver] = useState(false); - const [isImporting, setIsImporting] = useState(false); const [lastError, setLastError] = useState(null); + const [hoveredTarget, setHoveredTarget] = useState(null); + const targetsRef = useRef({}); - const handleDrop = useCallback( + const handleImport = useCallback( async (paths: string[]) => { - setIsImporting(true); setLastError(null); try { // Resolve a target library — auto-create one if the profile @@ -50,18 +104,21 @@ export function useDragDropImport(): { libId = libraries[0].id; selectLibrary(libId); } else { - const lib = await createLibrary({ name: t("onboarding.defaultLibraryName") }); + const lib = await createLibrary({ + name: t("onboarding.defaultLibraryName"), + }); libId = lib.id; selectLibrary(libId); } } - await importPaths(libId, paths); + const summary = await importDropped(libId, paths); + announceImport({ kind: "dropped", summary }); await refresh(); } catch (err) { console.error("[useDragDropImport] import failed", err); + const overlap = parseFolderOverlap(err); + if (overlap) announceImport({ kind: "overlap", overlap }); setLastError(String(err)); - } finally { - setIsImporting(false); } }, [libraries, selectedLibraryId, selectLibrary, createLibrary, refresh, t], @@ -70,6 +127,20 @@ export function useDragDropImport(): { useEffect(() => { let unlisten: (() => void) | null = null; let cancelled = false; + // Where the pointer is, in CSS pixels, when the event says. + const pointOf = (payload: DragPayload) => + payload.position + ? { + x: payload.position.x / window.devicePixelRatio, + y: payload.position.y / window.devicePixelRatio, + } + : null; + let hovered: DropTarget | null = null; + const hover = (next: DropTarget | null) => { + if (next === hovered) return; + hovered = next; + setHoveredTarget(next); + }; (async () => { const webview = getCurrentWebview(); @@ -77,17 +148,29 @@ export function useDragDropImport(): { const payload = event.payload as DragPayload; switch (payload.type) { case "enter": - case "over": + case "over": { setIsDraggingOver(true); + const point = pointOf(payload); + if (point) hover(targetAt(targetsRef.current, point)); return; + } case "leave": setIsDraggingOver(false); + hover(null); return; case "drop": { setIsDraggingOver(false); + const point = pointOf(payload); + const target = + (point && targetAt(targetsRef.current, point)) ?? hovered; + hover(null); const paths = payload.paths ?? []; if (paths.length === 0) return; - handleDrop(paths); + if (target === "play") { + void playWithoutImporting(paths); + } else { + void handleImport(paths); + } return; } } @@ -100,7 +183,7 @@ export function useDragDropImport(): { cancelled = true; unlisten?.(); }; - }, [handleDrop]); + }, [handleImport]); - return { isDraggingOver, isImporting, lastError }; + return { isDraggingOver, lastError, hoveredTarget, targetsRef }; } diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index dfd1f65d5..b87d39cd2 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -12,6 +12,8 @@ import { type ShortcutBindings, } from "../lib/shortcuts"; import { toggleLikeTrack } from "../lib/tauri/track"; +import { openFilesToPlay, openFolderToPlay } from "../lib/openToPlay"; +import { isStreamTrack } from "../lib/playerSources"; /** * Wires global keyboard shortcuts to PlayerContext actions. Mounted @@ -76,7 +78,13 @@ export function useGlobalShortcuts() { // A held F11 auto-repeats: one press, one switch. After the field // check, so a binding that types in a field still repeats there. - if (event.repeat && action === "toggleFullscreen") { + // Same for the pickers: a held Ctrl+O would stack dialogs. + if ( + event.repeat && + (action === "toggleFullscreen" || + action === "openFiles" || + action === "openFolder") + ) { event.preventDefault(); return; } @@ -154,9 +162,17 @@ async function dispatch( case "toggleFullscreen": await toggleWindowFullscreen(); break; + case "openFiles": + await openFilesToPlay(); + break; + case "openFolder": + await openFolderToPlay(); + break; case "toggleLike": { const track = player.currentTrack; - if (!track) return; + // A track with no library row has nothing to like, as its ♥ button + // is hidden. + if (!track || isStreamTrack(track)) return; try { await toggleLikeTrack(track.id); } catch (err) { diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 7547c3ed5..e2da17361 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -241,7 +241,8 @@ "artists": "الفنانون", "genres": "الأنواع", "years": "السنوات", - "folders": "المجلدات" + "folders": "المجلدات", + "playWithoutImportingAria": "تشغيل دون استيراد" }, "playlistSection": { "title": "قوائم التشغيل" @@ -1134,7 +1135,9 @@ "decoder-crashed": "أُعيد تشغيل محرك الصوت", "decoder-stopped": "توقف محرك الصوت بعد أخطاء متكررة", "offline": "وضع عدم الاتصال مفعّل", - "unknown": "فشل التشغيل" + "unknown": "فشل التشغيل", + "files-failed": "تعذّر تشغيل هذه الملفات", + "picker-failed": "تعذّر فتح منتقي الملفات" }, "notice": { "exclusive-refused": "هذا الجهاز يرفض الوضع الحصري: التشغيل في الوضع المشترك", @@ -1769,9 +1772,14 @@ "reasonMissing": "الملف غير موجود (محذوف، أو على محرك غير متصل)؛ سيحدّث الفحص التالي المكتبة" }, "dragDrop": { - "dropHint": "أفلت للاستيراد", - "importing": "جارٍ الاستيراد…", - "subtitle": "تُقبل المجلدات أو الملفات الصوتية" + "play": { + "title": "تشغيل الآن", + "hint": "دون إضافته إلى المكتبة" + }, + "import": { + "title": "إضافة إلى المكتبة", + "hint": "تُنسخ إلى مجلد WaveFlow في موسيقاك" + } }, "abLoop": { "setA": "تكرار A-B: تعيين النقطة A عند الموضع الحالي", @@ -2074,7 +2082,9 @@ "toggleLyrics": "إظهار/إخفاء لوحة الكلمات", "toggleLike": "إضافة/إزالة المقطع الحالي من المفضلة", "focusSearch": "الانتقال إلى البحث", - "toggleFullscreen": "ملء الشاشة" + "toggleFullscreen": "ملء الشاشة", + "openFiles": "تشغيل ملفات دون استيرادها", + "openFolder": "تشغيل مجلد دون استيراده" } }, "offlineMode": { @@ -3466,7 +3476,8 @@ "reconcile": "المطابقة", "upload": "الرفع", "backup": "النسخ الاحتياطي", - "thumbnails": "الصور المصغّرة" + "thumbnails": "الصور المصغّرة", + "drop_import": "النسخ إلى المكتبة" }, "more_zero": "لا مهام أخرى", "more_two": "مهمتان أخريان", @@ -3646,5 +3657,31 @@ "targetTaken": "يوجد ملف آخر باسم {{path}} بالفعل." }, "adopts": "هذا الملف موجود بالفعل في المكتبة كمقطع مستقل. تُحذف تلك الإدخالة مع سجلها القصير، ويتولى هذا المقطع ملفها." + }, + "openToPlay": { + "files": "تشغيل ملفات…", + "folder": "تشغيل مجلد…", + "filesTitle": "اختر الملفات المراد تشغيلها", + "folderTitle": "اختر المجلد المراد تشغيله", + "filterName": "ملفات صوتية" + }, + "importNotice": { + "dropped": { + "title": "أُضيف إلى المكتبة", + "titleNothing": "لا جديد لإضافته", + "copied": "الملفات المنسوخة: {{n}}", + "alreadyCopied": "منسوخة سابقًا: {{n}}", + "inLibrary": "موجودة في المكتبة، تم تخطيها: {{n}}", + "refused": "مجلدات مرفوضة لأنها تحتوي على مجلد من المكتبة: {{n}}", + "errors": "عمليات نسخ فاشلة: {{n}}", + "truncated": "تم بلوغ الحد: أفلت الباقي على عدة دفعات.", + "cancelled": "توقف النسخ؛ أُضيف ما نُسخ بالفعل." + }, + "overlap": { + "title": "المجلد مشمول بالفعل", + "same": "هذا المجلد موجود بالفعل في المكتبة: {{folder}}", + "inside": "هذا المجلد موجود بالفعل في المكتبة، داخل {{folder}}.", + "contains": "يحتوي هذا المجلد على {{folder}} الموجود بالفعل في المكتبة. أضف مجلداته الفرعية الأخرى بدلًا من ذلك." + } } } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ed42645b9..c2936c895 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -229,7 +229,8 @@ "artists": "Künstler", "genres": "Genres", "years": "Jahre", - "folders": "Ordner" + "folders": "Ordner", + "playWithoutImportingAria": "Ohne Import abspielen" }, "playlistSection": { "title": "Playlists" @@ -996,7 +997,9 @@ "decoder-crashed": "Die Audio-Engine wurde neu gestartet", "decoder-stopped": "Die Audio-Engine wurde nach wiederholten Fehlern gestoppt", "offline": "Der Offline-Modus ist aktiviert", - "unknown": "Die Wiedergabe ist fehlgeschlagen" + "unknown": "Die Wiedergabe ist fehlgeschlagen", + "files-failed": "Diese Dateien können nicht abgespielt werden", + "picker-failed": "Die Dateiauswahl lässt sich nicht öffnen" }, "notice": { "exclusive-refused": "Dieses Gerät verweigert den Exklusivmodus: Wiedergabe im gemeinsamen Modus", @@ -1581,9 +1584,14 @@ "reasonMissing": "die Datei ist nicht da (gelöscht oder auf einem nicht verbundenen Laufwerk); der nächste Scan aktualisiert die Mediathek" }, "dragDrop": { - "dropHint": "Loslassen zum Importieren", - "importing": "Wird importiert…", - "subtitle": "Ordner oder Audiodateien akzeptiert" + "play": { + "title": "Jetzt abspielen", + "hint": "Ohne es zur Bibliothek hinzuzufügen" + }, + "import": { + "title": "Zur Bibliothek hinzufügen", + "hint": "In den WaveFlow-Ordner deiner Musik kopiert" + } }, "abLoop": { "setA": "A-B-Schleife: Punkt A an aktueller Position setzen", @@ -1875,7 +1883,9 @@ "toggleLyrics": "Songtext-Panel umschalten", "toggleLike": "Aktuellen Titel zu Favoriten hinzufügen / entfernen", "focusSearch": "Zur Suche springen", - "toggleFullscreen": "Vollbild" + "toggleFullscreen": "Vollbild", + "openFiles": "Dateien ohne Import abspielen", + "openFolder": "Ordner ohne Import abspielen" } }, "offlineMode": { @@ -3139,7 +3149,8 @@ "reconcile": "Abgleich", "upload": "Hochladen", "backup": "Sicherung", - "thumbnails": "Vorschaubilder" + "thumbnails": "Vorschaubilder", + "drop_import": "Kopieren in die Bibliothek" } }, "smartRuleSummary": { @@ -3275,5 +3286,31 @@ "targetTaken": "Eine andere Datei heißt bereits {{path}}." }, "adopts": "Diese Datei ist bereits als eigener Titel in der Mediathek. Dieser Eintrag wird samt seinem kurzen Verlauf entfernt, und dieser Titel übernimmt seine Datei." + }, + "openToPlay": { + "files": "Dateien abspielen…", + "folder": "Ordner abspielen…", + "filesTitle": "Dateien zum Abspielen auswählen", + "folderTitle": "Ordner zum Abspielen auswählen", + "filterName": "Audiodateien" + }, + "importNotice": { + "dropped": { + "title": "Zur Bibliothek hinzugefügt", + "titleNothing": "Nichts Neues hinzuzufügen", + "copied": "Kopierte Dateien: {{n}}", + "alreadyCopied": "Bereits früher kopiert: {{n}}", + "inLibrary": "Bereits in der Bibliothek, übersprungen: {{n}}", + "refused": "Abgelehnte Ordner, da sie einen Bibliotheksordner enthalten: {{n}}", + "errors": "Fehlgeschlagene Kopien: {{n}}", + "truncated": "Limit erreicht: Lege den Rest in mehreren Schritten ab.", + "cancelled": "Kopieren gestoppt; bereits Kopiertes wurde hinzugefügt." + }, + "overlap": { + "title": "Ordner bereits erfasst", + "same": "Dieser Ordner ist bereits in der Bibliothek: {{folder}}", + "inside": "Dieser Ordner ist bereits in der Bibliothek, innerhalb von {{folder}}.", + "contains": "Dieser Ordner enthält {{folder}}, der bereits in der Bibliothek ist. Füge stattdessen seine anderen Unterordner hinzu." + } } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 718d76f92..818e8da1c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -229,7 +229,8 @@ "artists": "Artists", "genres": "Genres", "years": "Years", - "folders": "Folders" + "folders": "Folders", + "playWithoutImportingAria": "Play without importing" }, "playlistSection": { "title": "Playlists" @@ -996,7 +997,9 @@ "decoder-crashed": "The audio engine restarted", "decoder-stopped": "The audio engine stopped after repeated errors", "offline": "Offline mode is on", - "unknown": "Playback failed" + "unknown": "Playback failed", + "files-failed": "Could not play these files", + "picker-failed": "Could not open the file picker" }, "notice": { "exclusive-refused": "This device refuses exclusive mode: playing in shared mode", @@ -1581,9 +1584,14 @@ "reasonMissing": "the file is not there (deleted, or on a drive that is not connected); the next scan updates the library" }, "dragDrop": { - "dropHint": "Drop to import", - "importing": "Importing…", - "subtitle": "Folders or audio files accepted" + "play": { + "title": "Play now", + "hint": "Without adding it to the library" + }, + "import": { + "title": "Add to the library", + "hint": "Copied into the WaveFlow folder of your music" + } }, "abLoop": { "setA": "A-B loop: set point A at the current position", @@ -1875,7 +1883,9 @@ "toggleLyrics": "Toggle lyrics panel", "toggleLike": "Like / Unlike current track", "focusSearch": "Focus search", - "toggleFullscreen": "Full screen" + "toggleFullscreen": "Full screen", + "openFiles": "Play files without importing them", + "openFolder": "Play a folder without importing it" } }, "offlineMode": { @@ -3139,7 +3149,8 @@ "reconcile": "Reconciliation", "upload": "Upload", "backup": "Backup", - "thumbnails": "Thumbnails" + "thumbnails": "Thumbnails", + "drop_import": "Copying to the library" } }, "smartRuleSummary": { @@ -3275,5 +3286,31 @@ "targetTaken": "Another file already has the name {{path}}." }, "adopts": "This file is already in the library as a track of its own. That entry is removed, with its short history, and this track takes its file." + }, + "openToPlay": { + "files": "Play files…", + "folder": "Play a folder…", + "filesTitle": "Choose the files to play", + "folderTitle": "Choose the folder to play", + "filterName": "Audio files" + }, + "importNotice": { + "dropped": { + "title": "Added to the library", + "titleNothing": "Nothing new to add", + "copied": "Files copied: {{n}}", + "alreadyCopied": "Already copied before: {{n}}", + "inLibrary": "Already in the library, skipped: {{n}}", + "refused": "Folders refused because they contain a library folder: {{n}}", + "errors": "Failed copies: {{n}}", + "truncated": "Limit reached: drop the rest in several goes.", + "cancelled": "Copy stopped; what was already copied has been added." + }, + "overlap": { + "title": "Folder already covered", + "same": "This folder is already in the library: {{folder}}", + "inside": "This folder is already in the library, inside {{folder}}.", + "contains": "This folder contains {{folder}}, which is already in the library. Add its other subfolders instead." + } } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f12536239..aa2d34c6f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -233,7 +233,8 @@ "artists": "Artistas", "genres": "Géneros", "years": "Años", - "folders": "Carpetas" + "folders": "Carpetas", + "playWithoutImportingAria": "Reproducir sin importar" }, "playlistSection": { "title": "Listas de reproducción" @@ -1036,7 +1037,9 @@ "decoder-crashed": "El motor de audio se ha reiniciado", "decoder-stopped": "El motor de audio se detuvo tras varios errores", "offline": "El modo sin conexión está activado", - "unknown": "La reproducción ha fallado" + "unknown": "La reproducción ha fallado", + "files-failed": "No se pueden reproducir estos archivos", + "picker-failed": "No se puede abrir el selector de archivos" }, "notice": { "exclusive-refused": "Este dispositivo rechaza el modo exclusivo: reproduciendo en modo compartido", @@ -1636,9 +1639,14 @@ "reasonMissing": "el archivo no está (eliminado o en una unidad no conectada); el próximo escaneo actualizará la biblioteca" }, "dragDrop": { - "dropHint": "Suelta para importar", - "importing": "Importando…", - "subtitle": "Se aceptan carpetas o archivos de audio" + "play": { + "title": "Reproducir ahora", + "hint": "Sin añadirlo a la biblioteca" + }, + "import": { + "title": "Añadir a la biblioteca", + "hint": "Copiados en la carpeta WaveFlow de tu música" + } }, "abLoop": { "setA": "Bucle A-B: marcar punto A en la posición actual", @@ -1933,7 +1941,9 @@ "toggleLyrics": "Mostrar/ocultar panel de letras", "toggleLike": "Me gusta / Quitar Me gusta a la pista actual", "focusSearch": "Ir a la búsqueda", - "toggleFullscreen": "Pantalla completa" + "toggleFullscreen": "Pantalla completa", + "openFiles": "Reproducir archivos sin importarlos", + "openFolder": "Reproducir una carpeta sin importarla" } }, "offlineMode": { @@ -3231,7 +3241,8 @@ "reconcile": "Reconciliación", "upload": "Subida", "backup": "Copia de seguridad", - "thumbnails": "Miniaturas" + "thumbnails": "Miniaturas", + "drop_import": "Copia a la biblioteca" } }, "smartRuleSummary": { @@ -3377,5 +3388,31 @@ "targetTaken": "Otro archivo ya se llama {{path}}." }, "adopts": "Este archivo ya está en la biblioteca como una canción aparte. Esa entrada se elimina, con su breve historial, y esta canción toma su archivo." + }, + "openToPlay": { + "files": "Reproducir archivos…", + "folder": "Reproducir una carpeta…", + "filesTitle": "Elige los archivos que reproducir", + "folderTitle": "Elige la carpeta que reproducir", + "filterName": "Archivos de audio" + }, + "importNotice": { + "dropped": { + "title": "Añadido a la biblioteca", + "titleNothing": "Nada nuevo que añadir", + "copied": "Archivos copiados: {{n}}", + "alreadyCopied": "Ya copiados antes: {{n}}", + "inLibrary": "Ya en la biblioteca, omitidos: {{n}}", + "refused": "Carpetas rechazadas porque contienen una carpeta de la biblioteca: {{n}}", + "errors": "Copias fallidas: {{n}}", + "truncated": "Límite alcanzado: suelta el resto en varias veces.", + "cancelled": "Copia detenida; lo que ya se había copiado se ha añadido." + }, + "overlap": { + "title": "Carpeta ya incluida", + "same": "Esta carpeta ya está en la biblioteca: {{folder}}", + "inside": "Esta carpeta ya está en la biblioteca, dentro de {{folder}}.", + "contains": "Esta carpeta contiene {{folder}}, que ya está en la biblioteca. Añade mejor sus otras subcarpetas." + } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e47570218..9f03885df 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -233,7 +233,8 @@ "artists": "Artistes", "genres": "Genres", "years": "Années", - "folders": "Dossiers" + "folders": "Dossiers", + "playWithoutImportingAria": "Lire sans importer" }, "playlistSection": { "title": "Playlists" @@ -1036,7 +1037,9 @@ "decoder-crashed": "Le moteur audio a redémarré", "decoder-stopped": "Le moteur audio s'est arrêté après plusieurs erreurs", "offline": "Le mode hors ligne est activé", - "unknown": "La lecture a échoué" + "unknown": "La lecture a échoué", + "files-failed": "Impossible de lire ces fichiers", + "picker-failed": "Impossible d'ouvrir le sélecteur de fichiers" }, "notice": { "exclusive-refused": "Ce périphérique refuse le mode exclusif : lecture en mode partagé", @@ -1756,9 +1759,14 @@ "reasonMissing": "le fichier est introuvable (supprimé, ou sur un disque non connecté) ; le prochain scan mettra la bibliothèque à jour" }, "dragDrop": { - "dropHint": "Déposer pour importer", - "importing": "Import en cours…", - "subtitle": "Dossiers ou fichiers audio acceptés" + "play": { + "title": "Lire maintenant", + "hint": "Sans l'ajouter à la bibliothèque" + }, + "import": { + "title": "Ajouter à la bibliothèque", + "hint": "Copiés dans le dossier WaveFlow de votre musique" + } }, "abLoop": { "setA": "Boucle A-B : définir le point A à la position actuelle", @@ -2013,7 +2021,9 @@ "toggleLyrics": "Afficher / masquer les paroles", "toggleLike": "Ajouter / Retirer des coups de cœur", "focusSearch": "Aller à la recherche", - "toggleFullscreen": "Plein écran" + "toggleFullscreen": "Plein écran", + "openFiles": "Lire des fichiers sans les importer", + "openFolder": "Lire un dossier sans l'importer" } }, "offlineMode": { @@ -3231,7 +3241,8 @@ "reconcile": "Réconciliation", "upload": "Téléversement", "backup": "Sauvegarde", - "thumbnails": "Vignettes" + "thumbnails": "Vignettes", + "drop_import": "Copie vers la bibliothèque" } }, "smartRuleSummary": { @@ -3377,5 +3388,31 @@ "targetTaken": "Un autre fichier porte déjà le nom {{path}}." }, "adopts": "Ce fichier est déjà dans la bibliothèque comme un morceau à part. Cette entrée est supprimée, avec son court historique, et ce morceau-ci reprend son fichier." + }, + "openToPlay": { + "files": "Lire des fichiers…", + "folder": "Lire un dossier…", + "filesTitle": "Choisir les fichiers à lire", + "folderTitle": "Choisir le dossier à lire", + "filterName": "Fichiers audio" + }, + "importNotice": { + "dropped": { + "title": "Ajouté à la bibliothèque", + "titleNothing": "Rien de nouveau à ajouter", + "copied": "Fichiers copiés : {{n}}", + "alreadyCopied": "Déjà copiés auparavant : {{n}}", + "inLibrary": "Déjà dans la bibliothèque, ignorés : {{n}}", + "refused": "Dossiers refusés, car ils contiennent un dossier de la bibliothèque : {{n}}", + "errors": "Copies échouées : {{n}}", + "truncated": "Limite atteinte : déposez le reste en plusieurs fois.", + "cancelled": "Copie arrêtée ; ce qui était déjà copié a été ajouté." + }, + "overlap": { + "title": "Dossier déjà couvert", + "same": "Ce dossier est déjà dans la bibliothèque : {{folder}}", + "inside": "Ce dossier est déjà dans la bibliothèque, à l'intérieur de {{folder}}.", + "contains": "Ce dossier contient {{folder}}, qui est déjà dans la bibliothèque. Ajoutez plutôt ses autres sous-dossiers." + } } } diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 96c246641..fa6c17065 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -229,7 +229,8 @@ "artists": "कलाकार", "genres": "शैलियाँ", "years": "वर्ष", - "folders": "फ़ोल्डर" + "folders": "फ़ोल्डर", + "playWithoutImportingAria": "इम्पोर्ट किए बिना चलाएँ" }, "playlistSection": { "title": "प्लेलिस्ट" @@ -996,7 +997,9 @@ "decoder-crashed": "ऑडियो इंजन फिर से शुरू हुआ", "decoder-stopped": "बार-बार त्रुटियों के बाद ऑडियो इंजन रुक गया", "offline": "ऑफ़लाइन मोड चालू है", - "unknown": "प्लेबैक विफल रहा" + "unknown": "प्लेबैक विफल रहा", + "files-failed": "इन फ़ाइलों को नहीं चलाया जा सका", + "picker-failed": "फ़ाइल चयनकर्ता नहीं खोला जा सका" }, "notice": { "exclusive-refused": "यह डिवाइस एक्सक्लूसिव मोड स्वीकार नहीं करता: साझा मोड में चल रहा है", @@ -1581,9 +1584,14 @@ "reasonMissing": "फ़ाइल वहाँ नहीं है (हटाई गई, या ऐसी ड्राइव पर जो जुड़ी नहीं है); अगला स्कैन लाइब्रेरी अपडेट करेगा" }, "dragDrop": { - "dropHint": "आयात करने के लिए छोड़ें", - "importing": "आयात हो रहा है…", - "subtitle": "फ़ोल्डर या ऑडियो फ़ाइलें स्वीकार" + "play": { + "title": "अभी चलाएँ", + "hint": "लाइब्रेरी में जोड़े बिना" + }, + "import": { + "title": "लाइब्रेरी में जोड़ें", + "hint": "आपके संगीत के WaveFlow फ़ोल्डर में कॉपी" + } }, "abLoop": { "setA": "A-B लूप: वर्तमान स्थिति पर बिंदु A सेट करें", @@ -1875,7 +1883,9 @@ "toggleLyrics": "बोल दिखाएँ / छिपाएँ", "toggleLike": "पसंदीदा में जोड़ें / हटाएँ", "focusSearch": "खोज पर जाएँ", - "toggleFullscreen": "पूर्ण स्क्रीन" + "toggleFullscreen": "पूर्ण स्क्रीन", + "openFiles": "फ़ाइलें इम्पोर्ट किए बिना चलाएँ", + "openFolder": "फ़ोल्डर इम्पोर्ट किए बिना चलाएँ" } }, "offlineMode": { @@ -3139,7 +3149,8 @@ "reconcile": "मिलान", "upload": "अपलोड", "backup": "बैकअप", - "thumbnails": "थंबनेल" + "thumbnails": "थंबनेल", + "drop_import": "लाइब्रेरी में कॉपी" } }, "smartRuleSummary": { @@ -3275,5 +3286,31 @@ "targetTaken": "{{path}} नाम की फ़ाइल पहले से मौजूद है।" }, "adopts": "यह फ़ाइल लाइब्रेरी में पहले से अलग ट्रैक के रूप में है। वह प्रविष्टि अपने छोटे इतिहास के साथ हटा दी जाती है, और यह ट्रैक उसकी फ़ाइल ले लेता है।" + }, + "openToPlay": { + "files": "फ़ाइलें चलाएँ…", + "folder": "फ़ोल्डर चलाएँ…", + "filesTitle": "चलाने के लिए फ़ाइलें चुनें", + "folderTitle": "चलाने के लिए फ़ोल्डर चुनें", + "filterName": "ऑडियो फ़ाइलें" + }, + "importNotice": { + "dropped": { + "title": "लाइब्रेरी में जोड़ा गया", + "titleNothing": "जोड़ने के लिए कुछ नया नहीं", + "copied": "कॉपी की गई फ़ाइलें: {{n}}", + "alreadyCopied": "पहले से कॉपी की गई: {{n}}", + "inLibrary": "पहले से लाइब्रेरी में, छोड़ी गईं: {{n}}", + "refused": "लाइब्रेरी फ़ोल्डर होने के कारण अस्वीकृत फ़ोल्डर: {{n}}", + "errors": "विफल कॉपी: {{n}}", + "truncated": "सीमा पूरी हुई: बाकी को कई बार में छोड़ें।", + "cancelled": "कॉपी रोकी गई; जो पहले से कॉपी हो चुका था, वह जोड़ दिया गया।" + }, + "overlap": { + "title": "फ़ोल्डर पहले से शामिल है", + "same": "यह फ़ोल्डर पहले से लाइब्रेरी में है: {{folder}}", + "inside": "यह फ़ोल्डर पहले से लाइब्रेरी में है, {{folder}} के अंदर।", + "contains": "इस फ़ोल्डर में {{folder}} है, जो पहले से लाइब्रेरी में है। इसके बजाय इसके अन्य सब-फ़ोल्डर जोड़ें।" + } } } diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 11eea8230..7f2657dad 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -229,7 +229,8 @@ "artists": "Artis", "genres": "Genre", "years": "Tahun", - "folders": "Folder" + "folders": "Folder", + "playWithoutImportingAria": "Putar tanpa mengimpor" }, "playlistSection": { "title": "Daftar putar" @@ -994,7 +995,9 @@ "decoder-crashed": "Mesin audio dimulai ulang", "decoder-stopped": "Mesin audio berhenti setelah beberapa kesalahan", "offline": "Mode luring aktif", - "unknown": "Pemutaran gagal" + "unknown": "Pemutaran gagal", + "files-failed": "Tidak dapat memutar file ini", + "picker-failed": "Tidak dapat membuka pemilih file" }, "notice": { "exclusive-refused": "Perangkat ini menolak mode eksklusif: memutar dalam mode bersama", @@ -1579,9 +1582,14 @@ "reasonMissing": "file tidak ada (dihapus, atau di drive yang tidak terhubung); pemindaian berikutnya memperbarui pustaka" }, "dragDrop": { - "dropHint": "Lepaskan untuk mengimpor", - "importing": "Mengimpor…", - "subtitle": "Menerima folder atau berkas audio" + "play": { + "title": "Putar sekarang", + "hint": "Tanpa menambahkannya ke pustaka" + }, + "import": { + "title": "Tambahkan ke pustaka", + "hint": "Disalin ke folder WaveFlow di musik Anda" + } }, "abLoop": { "setA": "Loop A-B: tandai titik A di posisi saat ini", @@ -1873,7 +1881,9 @@ "toggleLyrics": "Tampilkan/sembunyikan panel lirik", "toggleLike": "Sukai / Batalkan suka untuk lagu saat ini", "focusSearch": "Ke pencarian", - "toggleFullscreen": "Layar penuh" + "toggleFullscreen": "Layar penuh", + "openFiles": "Putar file tanpa mengimpornya", + "openFolder": "Putar folder tanpa mengimpornya" } }, "offlineMode": { @@ -3137,7 +3147,8 @@ "reconcile": "Rekonsiliasi", "upload": "Unggah", "backup": "Cadangan", - "thumbnails": "Gambar mini" + "thumbnails": "Gambar mini", + "drop_import": "Menyalin ke pustaka" } }, "smartRuleSummary": { @@ -3273,5 +3284,31 @@ "targetTaken": "File lain sudah bernama {{path}}." }, "adopts": "File ini sudah ada di pustaka sebagai lagu tersendiri. Entri itu dihapus beserta riwayat singkatnya, dan lagu ini mengambil filenya." + }, + "openToPlay": { + "files": "Putar file…", + "folder": "Putar folder…", + "filesTitle": "Pilih file yang akan diputar", + "folderTitle": "Pilih folder yang akan diputar", + "filterName": "File audio" + }, + "importNotice": { + "dropped": { + "title": "Ditambahkan ke pustaka", + "titleNothing": "Tidak ada yang baru untuk ditambahkan", + "copied": "File disalin: {{n}}", + "alreadyCopied": "Sudah disalin sebelumnya: {{n}}", + "inLibrary": "Sudah ada di pustaka, dilewati: {{n}}", + "refused": "Folder ditolak karena berisi folder pustaka: {{n}}", + "errors": "Salinan gagal: {{n}}", + "truncated": "Batas tercapai: jatuhkan sisanya dalam beberapa kali.", + "cancelled": "Penyalinan dihentikan; yang sudah tersalin telah ditambahkan." + }, + "overlap": { + "title": "Folder sudah tercakup", + "same": "Folder ini sudah ada di pustaka: {{folder}}", + "inside": "Folder ini sudah ada di pustaka, di dalam {{folder}}.", + "contains": "Folder ini berisi {{folder}}, yang sudah ada di pustaka. Tambahkan subfolder lainnya saja." + } } } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 1cac1b624..48d57bfb8 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -233,7 +233,8 @@ "artists": "Artisti", "genres": "Generi", "years": "Anni", - "folders": "Cartelle" + "folders": "Cartelle", + "playWithoutImportingAria": "Riproduci senza importare" }, "playlistSection": { "title": "Playlist" @@ -1036,7 +1037,9 @@ "decoder-crashed": "Il motore audio è stato riavviato", "decoder-stopped": "Il motore audio si è fermato dopo ripetuti errori", "offline": "La modalità offline è attiva", - "unknown": "Riproduzione non riuscita" + "unknown": "Riproduzione non riuscita", + "files-failed": "Impossibile riprodurre questi file", + "picker-failed": "Impossibile aprire il selettore di file" }, "notice": { "exclusive-refused": "Questo dispositivo rifiuta la modalità esclusiva: riproduzione in modalità condivisa", @@ -1636,9 +1639,14 @@ "reasonMissing": "il file non c'è (eliminato o su un'unità non collegata); la prossima scansione aggiornerà la libreria" }, "dragDrop": { - "dropHint": "Rilascia per importare", - "importing": "Importazione…", - "subtitle": "Cartelle o file audio accettati" + "play": { + "title": "Riproduci ora", + "hint": "Senza aggiungerlo alla libreria" + }, + "import": { + "title": "Aggiungi alla libreria", + "hint": "Copiati nella cartella WaveFlow della tua musica" + } }, "abLoop": { "setA": "Loop A-B: imposta il punto A alla posizione corrente", @@ -1933,7 +1941,9 @@ "toggleLyrics": "Mostra/nascondi il pannello dei testi", "toggleLike": "Aggiungi / rimuovi dai preferiti il brano corrente", "focusSearch": "Vai alla ricerca", - "toggleFullscreen": "Schermo intero" + "toggleFullscreen": "Schermo intero", + "openFiles": "Riproduci file senza importarli", + "openFolder": "Riproduci una cartella senza importarla" } }, "offlineMode": { @@ -3231,7 +3241,8 @@ "reconcile": "Riconciliazione", "upload": "Caricamento", "backup": "Backup", - "thumbnails": "Miniature" + "thumbnails": "Miniature", + "drop_import": "Copia nella libreria" } }, "smartRuleSummary": { @@ -3377,5 +3388,31 @@ "targetTaken": "Un altro file si chiama già {{path}}." }, "adopts": "Questo file è già nella libreria come brano a sé. Quella voce viene rimossa, con la sua breve cronologia, e questo brano ne prende il file." + }, + "openToPlay": { + "files": "Riproduci file…", + "folder": "Riproduci una cartella…", + "filesTitle": "Scegli i file da riprodurre", + "folderTitle": "Scegli la cartella da riprodurre", + "filterName": "File audio" + }, + "importNotice": { + "dropped": { + "title": "Aggiunto alla libreria", + "titleNothing": "Niente di nuovo da aggiungere", + "copied": "File copiati: {{n}}", + "alreadyCopied": "Già copiati in precedenza: {{n}}", + "inLibrary": "Già nella libreria, saltati: {{n}}", + "refused": "Cartelle rifiutate perché contengono una cartella della libreria: {{n}}", + "errors": "Copie non riuscite: {{n}}", + "truncated": "Limite raggiunto: rilascia il resto in più volte.", + "cancelled": "Copia interrotta; ciò che era già stato copiato è stato aggiunto." + }, + "overlap": { + "title": "Cartella già inclusa", + "same": "Questa cartella è già nella libreria: {{folder}}", + "inside": "Questa cartella è già nella libreria, dentro {{folder}}.", + "contains": "Questa cartella contiene {{folder}}, che è già nella libreria. Aggiungi piuttosto le sue altre sottocartelle." + } } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 971a167bd..e669b0baf 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -229,7 +229,8 @@ "artists": "アーティスト", "genres": "ジャンル", "years": "年", - "folders": "フォルダ" + "folders": "フォルダ", + "playWithoutImportingAria": "インポートせずに再生" }, "playlistSection": { "title": "プレイリスト" @@ -994,7 +995,9 @@ "decoder-crashed": "オーディオエンジンを再起動しました", "decoder-stopped": "エラーが続いたためオーディオエンジンを停止しました", "offline": "オフラインモードが有効です", - "unknown": "再生に失敗しました" + "unknown": "再生に失敗しました", + "files-failed": "これらのファイルを再生できません", + "picker-failed": "ファイル選択ダイアログを開けません" }, "notice": { "exclusive-refused": "このデバイスは排他モードを受け付けません。共有モードで再生します", @@ -1579,9 +1582,14 @@ "reasonMissing": "ファイルが見つかりません(削除済み、または接続されていないドライブ上)。次回のスキャンでライブラリが更新されます" }, "dragDrop": { - "dropHint": "ドロップしてインポート", - "importing": "インポート中…", - "subtitle": "フォルダまたはオーディオファイルを受け付けます" + "play": { + "title": "今すぐ再生", + "hint": "ライブラリに追加せずに" + }, + "import": { + "title": "ライブラリに追加", + "hint": "ミュージックの WaveFlow フォルダーにコピー" + } }, "abLoop": { "setA": "A-Bループ:現在位置にA点を設定", @@ -2317,7 +2325,9 @@ "toggleLyrics": "歌詞を表示 / 非表示", "toggleLike": "いいね / 解除", "focusSearch": "検索に移動", - "toggleFullscreen": "全画面表示" + "toggleFullscreen": "全画面表示", + "openFiles": "ファイルをインポートせずに再生", + "openFolder": "フォルダーをインポートせずに再生" } }, "offlineMode": { @@ -3137,7 +3147,8 @@ "reconcile": "照合", "upload": "アップロード", "backup": "バックアップ", - "thumbnails": "サムネイル" + "thumbnails": "サムネイル", + "drop_import": "ライブラリへのコピー" } }, "smartRuleSummary": { @@ -3273,5 +3284,31 @@ "targetTaken": "{{path}} という名前のファイルがすでにあります。" }, "adopts": "このファイルはすでに別の曲としてライブラリにあります。その項目は短い履歴ごと削除され、この曲がそのファイルを引き継ぎます。" + }, + "openToPlay": { + "files": "ファイルを再生…", + "folder": "フォルダーを再生…", + "filesTitle": "再生するファイルを選択", + "folderTitle": "再生するフォルダーを選択", + "filterName": "オーディオファイル" + }, + "importNotice": { + "dropped": { + "title": "ライブラリに追加しました", + "titleNothing": "追加する新しいものはありません", + "copied": "コピーしたファイル: {{n}}", + "alreadyCopied": "以前にコピー済み: {{n}}", + "inLibrary": "ライブラリに既にあるためスキップ: {{n}}", + "refused": "ライブラリのフォルダーを含むため拒否したフォルダー: {{n}}", + "errors": "コピーに失敗: {{n}}", + "truncated": "上限に達しました: 残りは何回かに分けてドロップしてください。", + "cancelled": "コピーを停止しました。コピー済みのものは追加されています。" + }, + "overlap": { + "title": "フォルダーは既に含まれています", + "same": "このフォルダーは既にライブラリにあります: {{folder}}", + "inside": "このフォルダーは既にライブラリにあります({{folder}} の中)。", + "contains": "このフォルダーには、既にライブラリにある {{folder}} が含まれています。代わりに他のサブフォルダーを追加してください。" + } } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index c15ec06c6..99094b3a4 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -229,7 +229,8 @@ "artists": "아티스트", "genres": "장르", "years": "연도", - "folders": "폴더" + "folders": "폴더", + "playWithoutImportingAria": "가져오지 않고 재생" }, "playlistSection": { "title": "재생 목록" @@ -994,7 +995,9 @@ "decoder-crashed": "오디오 엔진이 다시 시작되었습니다", "decoder-stopped": "오류가 반복되어 오디오 엔진이 중지되었습니다", "offline": "오프라인 모드가 켜져 있습니다", - "unknown": "재생에 실패했습니다" + "unknown": "재생에 실패했습니다", + "files-failed": "이 파일들을 재생할 수 없습니다", + "picker-failed": "파일 선택기를 열 수 없습니다" }, "notice": { "exclusive-refused": "이 장치는 독점 모드를 허용하지 않습니다. 공유 모드로 재생합니다", @@ -1579,9 +1582,14 @@ "reasonMissing": "파일이 없습니다(삭제되었거나 연결되지 않은 드라이브에 있음). 다음 스캔에서 라이브러리가 업데이트됩니다" }, "dragDrop": { - "dropHint": "여기에 놓아 가져오기", - "importing": "가져오는 중…", - "subtitle": "폴더와 오디오 파일을 가져올 수 있습니다" + "play": { + "title": "지금 재생", + "hint": "보관함에 추가하지 않고" + }, + "import": { + "title": "보관함에 추가", + "hint": "음악의 WaveFlow 폴더로 복사됨" + } }, "abLoop": { "setA": "A-B 루프: 현재 위치에 A 지점 설정", @@ -1873,7 +1881,9 @@ "toggleLyrics": "가사 표시 / 숨기기", "toggleLike": "좋아요 / 좋아요 취소", "focusSearch": "검색으로 이동", - "toggleFullscreen": "전체 화면" + "toggleFullscreen": "전체 화면", + "openFiles": "파일을 가져오지 않고 재생", + "openFolder": "폴더를 가져오지 않고 재생" } }, "offlineMode": { @@ -3137,7 +3147,8 @@ "reconcile": "대조", "upload": "업로드", "backup": "백업", - "thumbnails": "썸네일" + "thumbnails": "썸네일", + "drop_import": "보관함으로 복사" } }, "smartRuleSummary": { @@ -3273,5 +3284,31 @@ "targetTaken": "다른 파일이 이미 {{path}} 이름을 쓰고 있습니다." }, "adopts": "이 파일은 이미 라이브러리에 별도의 곡으로 있습니다. 그 항목은 짧은 기록과 함께 삭제되고, 이 곡이 그 파일을 이어받습니다." + }, + "openToPlay": { + "files": "파일 재생…", + "folder": "폴더 재생…", + "filesTitle": "재생할 파일 선택", + "folderTitle": "재생할 폴더 선택", + "filterName": "오디오 파일" + }, + "importNotice": { + "dropped": { + "title": "보관함에 추가됨", + "titleNothing": "새로 추가할 항목이 없습니다", + "copied": "복사한 파일: {{n}}", + "alreadyCopied": "이전에 이미 복사됨: {{n}}", + "inLibrary": "이미 보관함에 있어 건너뜀: {{n}}", + "refused": "보관함 폴더를 포함해 거부된 폴더: {{n}}", + "errors": "복사 실패: {{n}}", + "truncated": "한도에 도달했습니다: 나머지는 여러 번에 나눠 놓으세요.", + "cancelled": "복사가 중지되었습니다. 이미 복사된 항목은 추가되었습니다." + }, + "overlap": { + "title": "이미 포함된 폴더", + "same": "이 폴더는 이미 보관함에 있습니다: {{folder}}", + "inside": "이 폴더는 이미 보관함의 {{folder}} 안에 있습니다.", + "contains": "이 폴더에는 이미 보관함에 있는 {{folder}}이(가) 들어 있습니다. 대신 다른 하위 폴더를 추가하세요." + } } } diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 889e71da3..9b8900c54 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -229,7 +229,8 @@ "artists": "Artiesten", "genres": "Genres", "years": "Jaren", - "folders": "Mappen" + "folders": "Mappen", + "playWithoutImportingAria": "Afspelen zonder importeren" }, "playlistSection": { "title": "Afspeellijsten" @@ -996,7 +997,9 @@ "decoder-crashed": "De audio-engine is opnieuw gestart", "decoder-stopped": "De audio-engine is gestopt na herhaalde fouten", "offline": "Offlinemodus staat aan", - "unknown": "Afspelen is mislukt" + "unknown": "Afspelen is mislukt", + "files-failed": "Deze bestanden kunnen niet worden afgespeeld", + "picker-failed": "De bestandskiezer kan niet worden geopend" }, "notice": { "exclusive-refused": "Dit apparaat weigert de exclusieve modus: afspelen in gedeelde modus", @@ -1581,9 +1584,14 @@ "reasonMissing": "het bestand is er niet (verwijderd of op een niet-aangesloten schijf); de volgende scan werkt de bibliotheek bij" }, "dragDrop": { - "dropHint": "Loslaten om te importeren", - "importing": "Importeren…", - "subtitle": "Mappen of audiobestanden geaccepteerd" + "play": { + "title": "Nu afspelen", + "hint": "Zonder het aan de bibliotheek toe te voegen" + }, + "import": { + "title": "Aan de bibliotheek toevoegen", + "hint": "Gekopieerd naar de WaveFlow-map van je muziek" + } }, "abLoop": { "setA": "A-B-loop: punt A op huidige positie instellen", @@ -1875,7 +1883,9 @@ "toggleLyrics": "Songtekstpaneel in-/uitschakelen", "toggleLike": "Huidig nummer favoriet maken / verwijderen", "focusSearch": "Naar zoeken", - "toggleFullscreen": "Volledig scherm" + "toggleFullscreen": "Volledig scherm", + "openFiles": "Bestanden afspelen zonder ze te importeren", + "openFolder": "Een map afspelen zonder hem te importeren" } }, "offlineMode": { @@ -3139,7 +3149,8 @@ "reconcile": "Afstemming", "upload": "Uploaden", "backup": "Back-up", - "thumbnails": "Miniaturen" + "thumbnails": "Miniaturen", + "drop_import": "Kopiëren naar de bibliotheek" } }, "smartRuleSummary": { @@ -3275,5 +3286,31 @@ "targetTaken": "Een ander bestand heet al {{path}}." }, "adopts": "Dit bestand staat al als apart nummer in de bibliotheek. Die vermelding wordt verwijderd, met haar korte geschiedenis, en dit nummer neemt het bestand over." + }, + "openToPlay": { + "files": "Bestanden afspelen…", + "folder": "Een map afspelen…", + "filesTitle": "Kies de bestanden om af te spelen", + "folderTitle": "Kies de map om af te spelen", + "filterName": "Audiobestanden" + }, + "importNotice": { + "dropped": { + "title": "Toegevoegd aan de bibliotheek", + "titleNothing": "Niets nieuws om toe te voegen", + "copied": "Gekopieerde bestanden: {{n}}", + "alreadyCopied": "Eerder al gekopieerd: {{n}}", + "inLibrary": "Al in de bibliotheek, overgeslagen: {{n}}", + "refused": "Mappen geweigerd omdat ze een bibliotheekmap bevatten: {{n}}", + "errors": "Mislukte kopieën: {{n}}", + "truncated": "Limiet bereikt: zet de rest in meerdere keren neer.", + "cancelled": "Kopiëren gestopt; wat al gekopieerd was, is toegevoegd." + }, + "overlap": { + "title": "Map al opgenomen", + "same": "Deze map staat al in de bibliotheek: {{folder}}", + "inside": "Deze map staat al in de bibliotheek, binnen {{folder}}.", + "contains": "Deze map bevat {{folder}}, die al in de bibliotheek staat. Voeg liever de andere submappen toe." + } } } diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index d26aebb4e..bf4199467 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -233,7 +233,8 @@ "artists": "Artistas", "genres": "Gêneros", "years": "Anos", - "folders": "Pastas" + "folders": "Pastas", + "playWithoutImportingAria": "Tocar sem importar" }, "playlistSection": { "title": "Playlists" @@ -1037,7 +1038,9 @@ "decoder-crashed": "O motor de áudio reiniciou", "decoder-stopped": "O motor de áudio parou após vários erros", "offline": "O modo offline está ativado", - "unknown": "A reprodução falhou" + "unknown": "A reprodução falhou", + "files-failed": "Não é possível tocar estes arquivos", + "picker-failed": "Não é possível abrir o seletor de arquivos" }, "notice": { "exclusive-refused": "Este dispositivo recusa o modo exclusivo: reproduzindo em modo compartilhado", @@ -1637,9 +1640,14 @@ "reasonMissing": "o arquivo não existe (excluído ou em uma unidade não conectada); a próxima varredura atualiza a biblioteca" }, "dragDrop": { - "dropHint": "Soltar para importar", - "importing": "Importando…", - "subtitle": "Pastas ou arquivos de áudio aceitos" + "play": { + "title": "Tocar agora", + "hint": "Sem adicioná-lo à biblioteca" + }, + "import": { + "title": "Adicionar à biblioteca", + "hint": "Copiados para a pasta WaveFlow da sua música" + } }, "abLoop": { "setA": "Loop A-B: definir o ponto A na posição atual", @@ -1934,7 +1942,9 @@ "toggleLyrics": "Mostrar / Ocultar letra", "toggleLike": "Curtir / Remover curtida", "focusSearch": "Ir para a busca", - "toggleFullscreen": "Tela cheia" + "toggleFullscreen": "Tela cheia", + "openFiles": "Tocar arquivos sem importá-los", + "openFolder": "Tocar uma pasta sem importá-la" } }, "offlineMode": { @@ -3232,7 +3242,8 @@ "reconcile": "Reconciliação", "upload": "Envio", "backup": "Backup", - "thumbnails": "Miniaturas" + "thumbnails": "Miniaturas", + "drop_import": "Cópia para a biblioteca" } }, "smartRuleSummary": { @@ -3378,5 +3389,31 @@ "targetTaken": "Outro arquivo já se chama {{path}}." }, "adopts": "Este arquivo já está na biblioteca como uma faixa separada. Essa entrada é removida, com seu breve histórico, e esta faixa passa a usar o arquivo." + }, + "openToPlay": { + "files": "Tocar arquivos…", + "folder": "Tocar uma pasta…", + "filesTitle": "Escolha os arquivos para tocar", + "folderTitle": "Escolha a pasta para tocar", + "filterName": "Arquivos de áudio" + }, + "importNotice": { + "dropped": { + "title": "Adicionado à biblioteca", + "titleNothing": "Nada de novo para adicionar", + "copied": "Arquivos copiados: {{n}}", + "alreadyCopied": "Já copiados antes: {{n}}", + "inLibrary": "Já na biblioteca, ignorados: {{n}}", + "refused": "Pastas recusadas por conterem uma pasta da biblioteca: {{n}}", + "errors": "Cópias com falha: {{n}}", + "truncated": "Limite atingido: solte o restante em várias vezes.", + "cancelled": "Cópia interrompida; o que já tinha sido copiado foi adicionado." + }, + "overlap": { + "title": "Pasta já incluída", + "same": "Esta pasta já está na biblioteca: {{folder}}", + "inside": "Esta pasta já está na biblioteca, dentro de {{folder}}.", + "contains": "Esta pasta contém {{folder}}, que já está na biblioteca. Adicione as outras subpastas dela." + } } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 721506b9e..42cf1e8a7 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -233,7 +233,8 @@ "artists": "Artistas", "genres": "Géneros", "years": "Anos", - "folders": "Pastas" + "folders": "Pastas", + "playWithoutImportingAria": "Reproduzir sem importar" }, "playlistSection": { "title": "Listas de reprodução" @@ -1037,7 +1038,9 @@ "decoder-crashed": "O motor de áudio reiniciou", "decoder-stopped": "O motor de áudio parou após vários erros", "offline": "O modo offline está ativado", - "unknown": "A reprodução falhou" + "unknown": "A reprodução falhou", + "files-failed": "Não é possível reproduzir estes ficheiros", + "picker-failed": "Não é possível abrir o seletor de ficheiros" }, "notice": { "exclusive-refused": "Este dispositivo recusa o modo exclusivo: a reproduzir em modo partilhado", @@ -1637,9 +1640,14 @@ "reasonMissing": "o ficheiro não existe (eliminado ou numa unidade não ligada); a próxima análise atualiza a biblioteca" }, "dragDrop": { - "dropHint": "Largar para importar", - "importing": "A importar…", - "subtitle": "Pastas ou ficheiros áudio aceites" + "play": { + "title": "Reproduzir agora", + "hint": "Sem o adicionar à biblioteca" + }, + "import": { + "title": "Adicionar à biblioteca", + "hint": "Copiados para a pasta WaveFlow da tua música" + } }, "abLoop": { "setA": "Ciclo A-B: definir o ponto A na posição atual", @@ -1934,7 +1942,9 @@ "toggleLyrics": "Mostrar / Ocultar letra", "toggleLike": "Adicionar / Remover dos favoritos", "focusSearch": "Ir para a pesquisa", - "toggleFullscreen": "Ecrã inteiro" + "toggleFullscreen": "Ecrã inteiro", + "openFiles": "Reproduzir ficheiros sem os importar", + "openFolder": "Reproduzir uma pasta sem a importar" } }, "offlineMode": { @@ -3232,7 +3242,8 @@ "reconcile": "Reconciliação", "upload": "Envio", "backup": "Cópia de segurança", - "thumbnails": "Miniaturas" + "thumbnails": "Miniaturas", + "drop_import": "Cópia para a biblioteca" } }, "smartRuleSummary": { @@ -3378,5 +3389,31 @@ "targetTaken": "Outro ficheiro já se chama {{path}}." }, "adopts": "Este ficheiro já está na biblioteca como uma faixa à parte. Essa entrada é removida, com o seu breve histórico, e esta faixa passa a usar o ficheiro." + }, + "openToPlay": { + "files": "Reproduzir ficheiros…", + "folder": "Reproduzir uma pasta…", + "filesTitle": "Escolhe os ficheiros a reproduzir", + "folderTitle": "Escolhe a pasta a reproduzir", + "filterName": "Ficheiros de áudio" + }, + "importNotice": { + "dropped": { + "title": "Adicionado à biblioteca", + "titleNothing": "Nada de novo para adicionar", + "copied": "Ficheiros copiados: {{n}}", + "alreadyCopied": "Já copiados antes: {{n}}", + "inLibrary": "Já na biblioteca, ignorados: {{n}}", + "refused": "Pastas recusadas por conterem uma pasta da biblioteca: {{n}}", + "errors": "Cópias falhadas: {{n}}", + "truncated": "Limite atingido: larga o resto em várias vezes.", + "cancelled": "Cópia interrompida; o que já tinha sido copiado foi adicionado." + }, + "overlap": { + "title": "Pasta já incluída", + "same": "Esta pasta já está na biblioteca: {{folder}}", + "inside": "Esta pasta já está na biblioteca, dentro de {{folder}}.", + "contains": "Esta pasta contém {{folder}}, que já está na biblioteca. Adiciona antes as outras subpastas." + } } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ac0f2a219..b13c37ad7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -237,7 +237,8 @@ "artists": "Исполнители", "genres": "Жанры", "years": "Годы", - "folders": "Папки" + "folders": "Папки", + "playWithoutImportingAria": "Воспроизвести без импорта" }, "playlistSection": { "title": "Плейлисты" @@ -1080,7 +1081,9 @@ "decoder-crashed": "Аудиодвижок перезапущен", "decoder-stopped": "Аудиодвижок остановлен после нескольких ошибок", "offline": "Включён офлайн-режим", - "unknown": "Воспроизведение не удалось" + "unknown": "Воспроизведение не удалось", + "files-failed": "Не удаётся воспроизвести эти файлы", + "picker-failed": "Не удаётся открыть окно выбора файлов" }, "notice": { "exclusive-refused": "Это устройство не принимает монопольный режим: воспроизведение в общем режиме", @@ -1695,9 +1698,14 @@ "reasonMissing": "файла нет на месте (удалён или на неподключённом диске); следующее сканирование обновит медиатеку" }, "dragDrop": { - "dropHint": "Отпустите, чтобы импортировать", - "importing": "Импорт…", - "subtitle": "Принимаются папки или аудиофайлы" + "play": { + "title": "Воспроизвести сейчас", + "hint": "Не добавляя в библиотеку" + }, + "import": { + "title": "Добавить в библиотеку", + "hint": "Скопировано в папку WaveFlow вашей музыки" + } }, "abLoop": { "setA": "A-B-повтор: установить точку A на текущей позиции", @@ -1995,7 +2003,9 @@ "toggleLyrics": "Показать/скрыть панель текста", "toggleLike": "Добавить в любимое / Убрать из любимого", "focusSearch": "Перейти к поиску", - "toggleFullscreen": "Полноэкранный режим" + "toggleFullscreen": "Полноэкранный режим", + "openFiles": "Воспроизвести файлы без импорта", + "openFolder": "Воспроизвести папку без импорта" } }, "offlineMode": { @@ -3325,7 +3335,8 @@ "reconcile": "Сопоставление", "upload": "Отправка", "backup": "Резервная копия", - "thumbnails": "Миниатюры" + "thumbnails": "Миниатюры", + "drop_import": "Копирование в библиотеку" }, "more_few": "Ещё {{count}} задачи", "more_many": "Ещё {{count}} задач" @@ -3483,5 +3494,31 @@ "targetTaken": "Другой файл уже называется {{path}}." }, "adopts": "Этот файл уже есть в медиатеке как отдельный трек. Та запись удаляется вместе с её короткой историей, а этот трек забирает её файл." + }, + "openToPlay": { + "files": "Воспроизвести файлы…", + "folder": "Воспроизвести папку…", + "filesTitle": "Выберите файлы для воспроизведения", + "folderTitle": "Выберите папку для воспроизведения", + "filterName": "Аудиофайлы" + }, + "importNotice": { + "dropped": { + "title": "Добавлено в библиотеку", + "titleNothing": "Нечего добавить", + "copied": "Скопировано файлов: {{n}}", + "alreadyCopied": "Уже скопировано ранее: {{n}}", + "inLibrary": "Уже в библиотеке, пропущено: {{n}}", + "refused": "Отклонено папок, содержащих папку библиотеки: {{n}}", + "errors": "Не удалось скопировать: {{n}}", + "truncated": "Достигнут предел: перетащите остальное в несколько приёмов.", + "cancelled": "Копирование остановлено; уже скопированное добавлено." + }, + "overlap": { + "title": "Папка уже охвачена", + "same": "Эта папка уже в библиотеке: {{folder}}", + "inside": "Эта папка уже в библиотеке, внутри {{folder}}.", + "contains": "Эта папка содержит {{folder}}, которая уже в библиотеке. Добавьте лучше её другие подпапки." + } } } diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 2a03927e7..2b836884a 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -229,7 +229,8 @@ "artists": "Sanatçılar", "genres": "Türler", "years": "Yıllar", - "folders": "Klasörler" + "folders": "Klasörler", + "playWithoutImportingAria": "İçe aktarmadan çal" }, "playlistSection": { "title": "Çalma listeleri" @@ -996,7 +997,9 @@ "decoder-crashed": "Ses motoru yeniden başlatıldı", "decoder-stopped": "Ses motoru tekrarlanan hatalardan sonra durdu", "offline": "Çevrimdışı mod açık", - "unknown": "Oynatma başarısız oldu" + "unknown": "Oynatma başarısız oldu", + "files-failed": "Bu dosyalar çalınamıyor", + "picker-failed": "Dosya seçici açılamıyor" }, "notice": { "exclusive-refused": "Bu aygıt özel modu kabul etmiyor: paylaşımlı modda çalınıyor", @@ -1581,9 +1584,14 @@ "reasonMissing": "dosya yerinde değil (silinmiş ya da bağlı olmayan bir sürücüde); sonraki tarama kitaplığı güncelleyecek" }, "dragDrop": { - "dropHint": "İçe aktarmak için bırak", - "importing": "İçe aktarılıyor…", - "subtitle": "Klasör veya ses dosyaları kabul edilir" + "play": { + "title": "Şimdi çal", + "hint": "Kitaplığa eklemeden" + }, + "import": { + "title": "Kitaplığa ekle", + "hint": "Müziğinizin WaveFlow klasörüne kopyalandı" + } }, "abLoop": { "setA": "A-B döngüsü: A noktasını mevcut konuma ayarla", @@ -1875,7 +1883,9 @@ "toggleLyrics": "Söz panelini aç/kapat", "toggleLike": "Mevcut parçayı beğen / beğenme", "focusSearch": "Aramaya git", - "toggleFullscreen": "Tam ekran" + "toggleFullscreen": "Tam ekran", + "openFiles": "Dosyaları içe aktarmadan çal", + "openFolder": "Bir klasörü içe aktarmadan çal" } }, "offlineMode": { @@ -3139,7 +3149,8 @@ "reconcile": "Eşleştirme", "upload": "Yükleme", "backup": "Yedekleme", - "thumbnails": "Kapak önizlemeleri" + "thumbnails": "Kapak önizlemeleri", + "drop_import": "Kitaplığa kopyalama" } }, "smartRuleSummary": { @@ -3275,5 +3286,31 @@ "targetTaken": "Başka bir dosya zaten {{path}} adını taşıyor." }, "adopts": "Bu dosya kitaplıkta zaten ayrı bir parça olarak var. O kayıt kısa geçmişiyle birlikte kaldırılır ve bu parça dosyasını devralır." + }, + "openToPlay": { + "files": "Dosyaları çal…", + "folder": "Bir klasörü çal…", + "filesTitle": "Çalınacak dosyaları seçin", + "folderTitle": "Çalınacak klasörü seçin", + "filterName": "Ses dosyaları" + }, + "importNotice": { + "dropped": { + "title": "Kitaplığa eklendi", + "titleNothing": "Eklenecek yeni bir şey yok", + "copied": "Kopyalanan dosyalar: {{n}}", + "alreadyCopied": "Daha önce kopyalanmış: {{n}}", + "inLibrary": "Zaten kitaplıkta, atlandı: {{n}}", + "refused": "Bir kitaplık klasörü içerdiği için reddedilen klasörler: {{n}}", + "errors": "Başarısız kopyalar: {{n}}", + "truncated": "Sınıra ulaşıldı: kalanını birkaç seferde bırakın.", + "cancelled": "Kopyalama durduruldu; zaten kopyalananlar eklendi." + }, + "overlap": { + "title": "Klasör zaten kapsanıyor", + "same": "Bu klasör zaten kitaplıkta: {{folder}}", + "inside": "Bu klasör zaten kitaplıkta, {{folder}} içinde.", + "contains": "Bu klasör, zaten kitaplıkta olan {{folder}} klasörünü içeriyor. Bunun yerine diğer alt klasörlerini ekleyin." + } } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 372f1f0ca..7a769a11a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -229,7 +229,8 @@ "artists": "艺人", "genres": "流派", "years": "年份", - "folders": "文件夹" + "folders": "文件夹", + "playWithoutImportingAria": "不导入直接播放" }, "playlistSection": { "title": "播放列表" @@ -994,7 +995,9 @@ "decoder-crashed": "音频引擎已重启", "decoder-stopped": "多次出错后音频引擎已停止", "offline": "离线模式已开启", - "unknown": "播放失败" + "unknown": "播放失败", + "files-failed": "无法播放这些文件", + "picker-failed": "无法打开文件选择器" }, "notice": { "exclusive-refused": "此设备不接受独占模式:以共享模式播放", @@ -1579,9 +1582,14 @@ "reasonMissing": "文件不在原处(已删除,或位于未连接的驱动器上);下次扫描会更新音乐库" }, "dragDrop": { - "dropHint": "松开以导入", - "importing": "正在导入…", - "subtitle": "支持文件夹或音频文件" + "play": { + "title": "立即播放", + "hint": "不添加到音乐库" + }, + "import": { + "title": "添加到音乐库", + "hint": "复制到音乐中的 WaveFlow 文件夹" + } }, "abLoop": { "setA": "A-B 循环:在当前位置设置 A 点", @@ -2317,7 +2325,9 @@ "toggleLyrics": "显示/隐藏歌词", "toggleLike": "喜欢/取消喜欢", "focusSearch": "跳转到搜索", - "toggleFullscreen": "全屏" + "toggleFullscreen": "全屏", + "openFiles": "不导入直接播放文件", + "openFolder": "不导入直接播放文件夹" } }, "offlineMode": { @@ -3137,7 +3147,8 @@ "reconcile": "比对", "upload": "上传", "backup": "备份", - "thumbnails": "缩略图" + "thumbnails": "缩略图", + "drop_import": "复制到音乐库" } }, "smartRuleSummary": { @@ -3273,5 +3284,31 @@ "targetTaken": "已有另一个文件名为 {{path}}。" }, "adopts": "此文件已作为单独的曲目在音乐库中。该条目及其简短的历史记录将被删除,由这首曲目接管其文件。" + }, + "openToPlay": { + "files": "播放文件…", + "folder": "播放文件夹…", + "filesTitle": "选择要播放的文件", + "folderTitle": "选择要播放的文件夹", + "filterName": "音频文件" + }, + "importNotice": { + "dropped": { + "title": "已添加到音乐库", + "titleNothing": "没有可添加的新内容", + "copied": "已复制文件:{{n}}", + "alreadyCopied": "之前已复制:{{n}}", + "inLibrary": "已在音乐库中,已跳过:{{n}}", + "refused": "因包含音乐库文件夹而被拒绝的文件夹:{{n}}", + "errors": "复制失败:{{n}}", + "truncated": "已达上限:请分几次拖放其余文件。", + "cancelled": "复制已停止;已复制的内容已添加。" + }, + "overlap": { + "title": "文件夹已包含在内", + "same": "此文件夹已在音乐库中:{{folder}}", + "inside": "此文件夹已在音乐库中,位于 {{folder}} 内。", + "contains": "此文件夹包含已在音乐库中的 {{folder}}。请改为添加它的其他子文件夹。" + } } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 347eae164..4c016ce66 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -229,7 +229,8 @@ "artists": "藝人", "genres": "曲風", "years": "年份", - "folders": "資料夾" + "folders": "資料夾", + "playWithoutImportingAria": "不匯入直接播放" }, "playlistSection": { "title": "播放清單" @@ -994,7 +995,9 @@ "decoder-crashed": "音訊引擎已重新啟動", "decoder-stopped": "多次錯誤後音訊引擎已停止", "offline": "離線模式已開啟", - "unknown": "播放失敗" + "unknown": "播放失敗", + "files-failed": "無法播放這些檔案", + "picker-failed": "無法開啟檔案選擇器" }, "notice": { "exclusive-refused": "此裝置不接受獨佔模式:以共用模式播放", @@ -1579,9 +1582,14 @@ "reasonMissing": "檔案不在原處(已刪除,或位於未連接的磁碟機上);下次掃描會更新音樂庫" }, "dragDrop": { - "dropHint": "拖放至此以匯入", - "importing": "正在匯入…", - "subtitle": "接受資料夾或音訊檔案" + "play": { + "title": "立即播放", + "hint": "不加入音樂庫" + }, + "import": { + "title": "加入音樂庫", + "hint": "複製到音樂中的 WaveFlow 資料夾" + } }, "abLoop": { "setA": "A-B 循環:在目前位置設定 A 點", @@ -2317,7 +2325,9 @@ "toggleLyrics": "顯示 / 隱藏歌詞", "toggleLike": "收藏 / 取消收藏", "focusSearch": "跳至搜尋", - "toggleFullscreen": "全螢幕" + "toggleFullscreen": "全螢幕", + "openFiles": "不匯入直接播放檔案", + "openFolder": "不匯入直接播放資料夾" } }, "offlineMode": { @@ -3137,7 +3147,8 @@ "reconcile": "比對", "upload": "上傳", "backup": "備份", - "thumbnails": "縮圖" + "thumbnails": "縮圖", + "drop_import": "複製到音樂庫" } }, "smartRuleSummary": { @@ -3273,5 +3284,31 @@ "targetTaken": "已有另一個檔案名為 {{path}}。" }, "adopts": "此檔案已作為單獨的曲目在音樂庫中。該項目及其簡短的歷史紀錄將被刪除,由這首曲目接手其檔案。" + }, + "openToPlay": { + "files": "播放檔案…", + "folder": "播放資料夾…", + "filesTitle": "選擇要播放的檔案", + "folderTitle": "選擇要播放的資料夾", + "filterName": "音訊檔" + }, + "importNotice": { + "dropped": { + "title": "已加入音樂庫", + "titleNothing": "沒有可加入的新內容", + "copied": "已複製檔案:{{n}}", + "alreadyCopied": "先前已複製:{{n}}", + "inLibrary": "已在音樂庫中,已略過:{{n}}", + "refused": "因包含音樂庫資料夾而被拒絕的資料夾:{{n}}", + "errors": "複製失敗:{{n}}", + "truncated": "已達上限:請分幾次拖放其餘檔案。", + "cancelled": "複製已停止;已複製的內容已加入。" + }, + "overlap": { + "title": "資料夾已包含在內", + "same": "此資料夾已在音樂庫中:{{folder}}", + "inside": "此資料夾已在音樂庫中,位於 {{folder}} 內。", + "contains": "此資料夾包含已在音樂庫中的 {{folder}}。請改為加入它的其他子資料夾。" + } } } diff --git a/src/lib/openToPlay.ts b/src/lib/openToPlay.ts new file mode 100644 index 000000000..2f3e8d56b --- /dev/null +++ b/src/lib/openToPlay.ts @@ -0,0 +1,95 @@ +import { emit } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-dialog"; +import i18n from "../i18n"; +import { playerPlayPaths } from "./tauri/player"; + +/** + * What the player decodes: `waveflow_core::scanner::extract::AUDIO_EXTENSIONS`. + * The picker offers these; anything else in a folder is passed over by the + * backend anyway. + */ +export const PLAYABLE_EXTENSIONS = [ + "mp3", + "flac", + "wav", + "aiff", + "aif", + "ogg", + "oga", + "opus", + "m4a", + "mp4", + "aac", + "dsf", + "dff", +]; + +/** + * Say a failure through the playback alert, as the engine's own are said: + * the drop, the menu and the shortcuts have nothing else on screen to show + * it, and a folder with no audio file in it would otherwise do nothing at + * all. The message stays in the console and in the alert's tooltip. + */ +function report(kind: "files-failed" | "picker-failed", err: unknown): void { + console.error(`[openToPlay] ${kind}`, err); + void emit("player:error", { kind, message: String(err) }).catch(() => {}); +} + +/** + * Play files or folders without importing them: the drop's "Play now" + * target and the two pickers below. Files that all are library tracks play + * as such; anything else plays as a queue of files (`player_play_paths`). + * Never rejects: a failure is reported to the user. + */ +export async function playWithoutImporting(paths: string[]): Promise { + if (paths.length === 0) return; + try { + await playerPlayPaths(paths); + } catch (err) { + report("files-failed", err); + } +} + +/** + * The picker's answer as a list: empty when it was cancelled, and when it + * failed, the failure then reported. + */ +async function pick(options: Parameters[0]): Promise { + try { + return asList(await open(options)); + } catch (err) { + report("picker-failed", err); + return []; + } +} + +function asList(result: string | string[] | null): string[] { + if (result == null) return []; + return Array.isArray(result) ? result : [result]; +} + +/** Pick audio files and play them without importing them. */ +export async function openFilesToPlay(): Promise { + const picked = await pick({ + multiple: true, + directory: false, + title: i18n.t("openToPlay.filesTitle"), + filters: [ + { + name: i18n.t("openToPlay.filterName"), + extensions: PLAYABLE_EXTENSIONS, + }, + ], + }); + await playWithoutImporting(picked); +} + +/** Pick a folder and play the audio files in it without importing them. */ +export async function openFolderToPlay(): Promise { + const picked = await pick({ + multiple: false, + directory: true, + title: i18n.t("openToPlay.folderTitle"), + }); + await playWithoutImporting(picked); +} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index b0281d0f3..568e3d948 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -22,7 +22,9 @@ export type ShortcutAction = | "toggleLyrics" | "toggleLike" | "focusSearch" - | "toggleFullscreen"; + | "toggleFullscreen" + | "openFiles" + | "openFolder"; export const SHORTCUT_ACTIONS: ShortcutAction[] = [ "togglePlayback", @@ -39,6 +41,8 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ "toggleLike", "focusSearch", "toggleFullscreen", + "openFiles", + "openFolder", ]; /** @@ -49,7 +53,7 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ * wanted. */ export const SHORTCUTS_ALLOWED_IN_FIELDS: ReadonlySet = new Set( - ["focusSearch", "toggleFullscreen"], + ["focusSearch", "toggleFullscreen", "openFiles", "openFolder"], ); /** What a text field does with a combo itself: never taken from it. */ @@ -116,6 +120,8 @@ export const DEFAULT_BINDINGS: Record = { toggleLike: "Shift+L", focusSearch: "Ctrl+K", toggleFullscreen: "F11", + openFiles: "Ctrl+O", + openFolder: "Ctrl+Shift+O", }; const SETTING_KEY = "ui.shortcuts"; diff --git a/src/lib/tauri/library.ts b/src/lib/tauri/library.ts index bfefcda20..55ed51fd3 100644 --- a/src/lib/tauri/library.ts +++ b/src/lib/tauri/library.ts @@ -98,6 +98,11 @@ export function rescanLibrary( return invoke("rescan_library", { libraryId, deep }); } +/** + * Register a folder of a library. Refused with a `FolderOverlap` error + * (see {@link parseFolderOverlap}) when it is, holds or lies under a + * folder already in the library. + */ export function addFolderToLibrary( libraryId: number, path: string, @@ -105,6 +110,53 @@ export function addFolderToLibrary( return invoke("add_folder_to_library", { libraryId, path }); } +/** How a folder lies relative to the library folder in its way. */ +export type FolderRelation = "same" | "inside" | "contains"; + +export interface FolderOverlap { + relation: FolderRelation; + /** The library folder in the way. */ + folder: string; +} + +/** + * The library folder an add or a drop ran into, read from the backend's + * `AppError::FolderOverlap` message, whose wording is the contract; `null` + * for any other error. + */ +export function parseFolderOverlap(err: unknown): FolderOverlap | null { + const match = + /^the folder (is inside|is|contains) the library folder (.+)$/.exec( + String(err), + ); + if (!match) return null; + const relation: FolderRelation = + match[1] === "is" + ? "same" + : match[1] === "is inside" + ? "inside" + : "contains"; + return { relation, folder: match[2] }; +} + +/** + * Something the import surfaces should tell the user: a folder refused + * for overlapping one in the library, or what a drop copied. Dispatched on + * `window` as {@link IMPORT_NOTICE_EVENT}, so every place that adds a + * folder is covered by one toast without knowing about it. + */ +export type ImportNotice = + | { kind: "overlap"; overlap: FolderOverlap } + | { kind: "dropped"; summary: DropImportSummary }; + +export const IMPORT_NOTICE_EVENT = "waveflow:import-notice"; + +export function announceImport(notice: ImportNotice): void { + window.dispatchEvent( + new CustomEvent(IMPORT_NOTICE_EVENT, { detail: notice }), + ); +} + /** * `deep: true` bypasses the scanner's `(mtime, size)` fast path so every * file in the folder is fully re-hashed and re-read (issue #366) — the @@ -189,25 +241,34 @@ export function countFolderPlayEvents(folderId: number): Promise { return invoke("count_folder_play_events", { folderId }); } +/** What a drop added to the library: `import_dropped`'s summary. */ +export interface DropImportSummary { + /** The folder WaveFlow manages, where the files were copied. */ + folder: string; + copied: number; + /** Already in the managed folder from an earlier drop. */ + already_copied: number; + /** Dropped items already under a library folder, passed over. */ + in_library: number; + /** Dropped folders holding a library folder, refused. */ + refused: number; + errors: number; + /** The drop held more files than one drop copies. */ + truncated: boolean; + cancelled: boolean; + scan: ScanSummary | null; +} + /** - * Import a list of arbitrary filesystem paths into a library. Used - * by the drag-and-drop handler — accepts a mix of folders and audio - * files (files contribute their parent directory). Adds each as a - * library_folder (skipping duplicates) and triggers a scan, then - * returns a single aggregated summary. + * Add dropped files and folders to a library by copying them into the + * folder WaveFlow manages under the user's music (`Music/WaveFlow`), then + * scanning it. The folders they came from are never registered. */ -export function importPaths( +export function importDropped( libraryId: number, paths: string[], -): Promise<{ - scanned: number; - added: number; - updated: number; - skipped: number; - errors: number; - removed: number; -}> { - return invoke("import_paths", { libraryId, paths }); +): Promise { + return invoke("import_dropped", { libraryId, paths }); } /** diff --git a/src/styles/skins/fluid.css b/src/styles/skins/fluid.css index 7b0808ffc..b86f3b4c6 100644 --- a/src/styles/skins/fluid.css +++ b/src/styles/skins/fluid.css @@ -2258,6 +2258,77 @@ background-color: var(--fe-hairline) !important; } +/* ── Drop targets: play now, or add to the library ────────── + Two surfaces over the dimmed page while files are dragged over the + window. No dashed outline: the one under the pointer steps up a tone, + takes the accent's edge and the cover's light below it, and its title + carries the mark — the open item, as everywhere else. */ +:root[data-layout="fluid"] [data-drop-overlay] { + gap: 1.25rem; + padding: 2.25rem; + background-color: color-mix(in srgb, var(--fe-bg) 72%, transparent); + backdrop-filter: blur(14px); +} +:root[data-layout="fluid"] [data-drop-target] { + border: 0; + border-radius: 1.25rem; + background-color: var(--fe-surface); + box-shadow: inset 0 0 0 1px var(--fe-hairline); + transition: + background-color 180ms var(--fe-ease), + box-shadow 180ms var(--fe-ease), + transform 320ms var(--fe-ease); +} +:root[data-layout="fluid"] [data-drop-ring] { + display: none; +} +:root[data-layout="fluid"] [data-drop-target][data-active] { + background-color: var(--fe-surface-2); + box-shadow: + inset 0 0 0 1.5px var(--fe-accent), + 0 28px 64px -32px var(--fe-glow); + transform: translateY(-3px); +} +:root[data-layout="fluid"] [data-drop-icon] { + color: var(--fe-ink-3); + transition: color 180ms var(--fe-ease); +} +:root[data-layout="fluid"] [data-drop-target][data-active] [data-drop-icon] { + color: var(--fe-accent); +} +:root[data-layout="fluid"] [data-drop-title] { + position: relative; + padding-bottom: 0.875rem; + font-family: var(--fe-font); + font-size: 1.625rem; + font-weight: 540; + letter-spacing: -0.022em; + color: var(--fe-ink); +} +:root[data-layout="fluid"] [data-drop-title]::after { + content: ""; + position: absolute; + left: 50%; + bottom: 0; + width: 2.5rem; + height: 0.625rem; + translate: -50% 0; + background-color: var(--fe-accent); + opacity: 0; + transition: opacity 180ms var(--fe-ease); + mask: url("data:image/svg+xml;utf8,") + no-repeat center / 100% 100%; + -webkit-mask: url("data:image/svg+xml;utf8,") + no-repeat center / 100% 100%; +} +:root[data-layout="fluid"] [data-drop-target][data-active] [data-drop-title]::after { + opacity: 1; +} +:root[data-layout="fluid"] [data-drop-hint] { + font-size: 0.9375rem; + color: var(--fe-ink-2); +} + /* ── Reduced motion: no drift, no stagger, no lift ─────────── */ @media (prefers-reduced-motion: reduce) { :root[data-layout="fluid"] .fe-rise, @@ -2279,7 +2350,10 @@ [data-immersive] ul > li - > button[class*="text-3xl"] { + > button[class*="text-3xl"], + :root[data-layout="fluid"] [data-drop-target], + :root[data-layout="fluid"] [data-drop-icon], + :root[data-layout="fluid"] [data-drop-title]::after { transition: none; } :root[data-layout="fluid"] .fe-tile-hover:hover .fe-tile, @@ -2287,7 +2361,8 @@ :root[data-layout="fluid"] .fe-press:active, :root[data-layout="fluid"] .fe-cta:hover, :root[data-layout="fluid"] .fe-cta:active, - :root[data-layout="fluid"] main [data-page-hero] button:hover:not(:disabled) { + :root[data-layout="fluid"] main [data-page-hero] button:hover:not(:disabled), + :root[data-layout="fluid"] [data-drop-target][data-active] { transform: none; } }