From 5e570638171ade1d402da767d92a1a410eabb761 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 07:14:04 +0200 Subject: [PATCH 1/6] feat(ui): play files without importing them from a drop, the sidebar and ctrl+o A drop on the window now offers two targets, play now or add to the library, picked by the pointer against the targets' own boxes. The sidebar's my music header gains a play without importing menu (files or a folder), bound to Ctrl+O and Ctrl+Shift+O. The overlay is portalled, keeps a visible ring in skins that restyle it as a panel, and follows Fluid Editorial in the fluid skin. --- docs/features/library.md | 4 +- docs/features/playback.md | 1 + docs/features/ui.md | 2 +- src/components/layout/AppLayout.tsx | 38 ++---- src/components/layout/DropTargetsOverlay.tsx | 134 +++++++++++++++++++ src/components/layout/Sidebar.tsx | 86 ++++++++++-- src/hooks/useDragDropImport.ts | 100 ++++++++++++-- src/hooks/useGlobalShortcuts.ts | 20 ++- src/i18n/locales/ar.json | 24 +++- src/i18n/locales/de.json | 24 +++- src/i18n/locales/en.json | 24 +++- src/i18n/locales/es.json | 24 +++- src/i18n/locales/fr.json | 24 +++- src/i18n/locales/hi.json | 24 +++- src/i18n/locales/id.json | 24 +++- src/i18n/locales/it.json | 24 +++- src/i18n/locales/ja.json | 24 +++- src/i18n/locales/ko.json | 24 +++- src/i18n/locales/nl.json | 24 +++- src/i18n/locales/pt-BR.json | 24 +++- src/i18n/locales/pt.json | 24 +++- src/i18n/locales/ru.json | 24 +++- src/i18n/locales/tr.json | 24 +++- src/i18n/locales/zh-CN.json | 24 +++- src/i18n/locales/zh-TW.json | 24 +++- src/lib/openToPlay.ts | 69 ++++++++++ src/lib/shortcuts.ts | 10 +- src/styles/skins/fluid.css | 93 ++++++++++++- 28 files changed, 839 insertions(+), 126 deletions(-) create mode 100644 src/components/layout/DropTargetsOverlay.tsx create mode 100644 src/lib/openToPlay.ts diff --git a/docs/features/library.md b/docs/features/library.md index 29be55746..be6d88b3a 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -247,7 +247,9 @@ UI: per-folder trash button in the Library → Folders tab, two-step confirm-on- 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 shows a spinner while an import's scan runs. Its base styling works in every skin; the Fluid skin recomposes it through `data-drop-*` landmarks (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..9a82d6260 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. - **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 a58e12942..c9da78962 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/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index fc4e64d4e..8292ce81b 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"; @@ -180,7 +179,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 +753,15 @@ 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. */} + {/* Drag-and-drop: two targets while files are dragged over the + window — play them now without importing them, or add them to + the library — then an "importing…" state while the scan runs. */} {(dragDrop.isDraggingOver || dragDrop.isImporting) && ( -
-
- {dragDrop.isImporting ? ( - - ) : ( - - )} -
-
- {dragDrop.isImporting - ? t("dragDrop.importing") - : t("dragDrop.dropHint")} -
-
- {t("dragDrop.subtitle")} -
-
-
-
+ )} {/* Main Container */} diff --git a/src/components/layout/DropTargetsOverlay.tsx b/src/components/layout/DropTargetsOverlay.tsx new file mode 100644 index 000000000..49e82001c --- /dev/null +++ b/src/components/layout/DropTargetsOverlay.tsx @@ -0,0 +1,134 @@ +import type { ReactNode, RefObject } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { FolderPlus, Loader2, 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. Base styling works in every skin; the Fluid skin recomposes + * it through the `data-drop-*` landmarks. + */ +export function DropTargetsOverlay({ + hoveredTarget, + isImporting, + targetsRef, +}: { + hoveredTarget: DropTarget | null; + isImporting: boolean; + targetsRef: RefObject; +}) { + const { t } = useTranslation(); + + if (isImporting) { + return createPortal( +
+
+ +
+ {t("dragDrop.importing")} +
+
+
, + document.body, + ); + } + + 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/hooks/useDragDropImport.ts b/src/hooks/useDragDropImport.ts index 6641a3b88..783bda271 100644 --- a/src/hooks/useDragDropImport.ts +++ b/src/hooks/useDragDropImport.ts @@ -1,29 +1,77 @@ -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 { 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>; + /** - * 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_paths` (a mix of folders and audio files, files contributing + * their parent directory). * - * 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, @@ -36,8 +84,10 @@ export function useDragDropImport(): { 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); @@ -70,6 +120,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 +141,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 +176,7 @@ export function useDragDropImport(): { cancelled = true; unlisten?.(); }; - }, [handleDrop]); + }, [handleImport]); - return { isDraggingOver, isImporting, lastError }; + return { isDraggingOver, isImporting, 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 d5ba7e80d..9d0a6e0a6 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": "قوائم التشغيل" @@ -1769,9 +1770,15 @@ "reasonMissing": "الملف غير موجود (محذوف، أو على محرك غير متصل)؛ سيحدّث الفحص التالي المكتبة" }, "dragDrop": { - "dropHint": "أفلت للاستيراد", "importing": "جارٍ الاستيراد…", - "subtitle": "تُقبل المجلدات أو الملفات الصوتية" + "play": { + "title": "تشغيل الآن", + "hint": "دون إضافته إلى المكتبة" + }, + "import": { + "title": "إضافة إلى المكتبة", + "hint": "مجلدات أو ملفات صوتية، تُفحص ثم تُتابَع" + } }, "abLoop": { "setA": "تكرار A-B: تعيين النقطة A عند الموضع الحالي", @@ -2074,7 +2081,9 @@ "toggleLyrics": "إظهار/إخفاء لوحة الكلمات", "toggleLike": "إضافة/إزالة المقطع الحالي من المفضلة", "focusSearch": "الانتقال إلى البحث", - "toggleFullscreen": "ملء الشاشة" + "toggleFullscreen": "ملء الشاشة", + "openFiles": "تشغيل ملفات دون استيرادها", + "openFolder": "تشغيل مجلد دون استيراده" } }, "offlineMode": { @@ -3646,5 +3655,12 @@ "targetTaken": "يوجد ملف آخر باسم {{path}} بالفعل." }, "adopts": "هذا الملف موجود بالفعل في المكتبة كمقطع مستقل. تُحذف تلك الإدخالة مع سجلها القصير، ويتولى هذا المقطع ملفها." + }, + "openToPlay": { + "files": "تشغيل ملفات…", + "folder": "تشغيل مجلد…", + "filesTitle": "اختر الملفات المراد تشغيلها", + "folderTitle": "اختر المجلد المراد تشغيله", + "filterName": "ملفات صوتية" } } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index e4b341a1c..1dde64cdd 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" @@ -1581,9 +1582,15 @@ "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": "Ordner oder Audiodateien, eingelesen und danach überwacht" + } }, "abLoop": { "setA": "A-B-Schleife: Punkt A an aktueller Position setzen", @@ -1875,7 +1882,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": { @@ -3275,5 +3284,12 @@ "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" } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bebcd5e3d..9f7a8b244 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" @@ -1581,9 +1582,15 @@ "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": "Folders or audio files, scanned and then watched" + } }, "abLoop": { "setA": "A-B loop: set point A at the current position", @@ -1875,7 +1882,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": { @@ -3275,5 +3284,12 @@ "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" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2fb4a8f9d..0dcfa779a 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" @@ -1636,9 +1637,15 @@ "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": "Carpetas o archivos de audio, analizados y luego vigilados" + } }, "abLoop": { "setA": "Bucle A-B: marcar punto A en la posición actual", @@ -1933,7 +1940,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": { @@ -3377,5 +3386,12 @@ "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" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a04ab0bdc..4ad8907c1 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" @@ -1756,9 +1757,15 @@ "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": "Dossiers ou fichiers audio, analysés puis suivis" + } }, "abLoop": { "setA": "Boucle A-B : définir le point A à la position actuelle", @@ -2013,7 +2020,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": { @@ -3377,5 +3386,12 @@ "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" } } diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 0fc4a52c9..a4484073b 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": "प्लेलिस्ट" @@ -1581,9 +1582,15 @@ "reasonMissing": "फ़ाइल वहाँ नहीं है (हटाई गई, या ऐसी ड्राइव पर जो जुड़ी नहीं है); अगला स्कैन लाइब्रेरी अपडेट करेगा" }, "dragDrop": { - "dropHint": "आयात करने के लिए छोड़ें", "importing": "आयात हो रहा है…", - "subtitle": "फ़ोल्डर या ऑडियो फ़ाइलें स्वीकार" + "play": { + "title": "अभी चलाएँ", + "hint": "लाइब्रेरी में जोड़े बिना" + }, + "import": { + "title": "लाइब्रेरी में जोड़ें", + "hint": "फ़ोल्डर या ऑडियो फ़ाइलें, स्कैन होकर फिर निगरानी में" + } }, "abLoop": { "setA": "A-B लूप: वर्तमान स्थिति पर बिंदु A सेट करें", @@ -1875,7 +1882,9 @@ "toggleLyrics": "बोल दिखाएँ / छिपाएँ", "toggleLike": "पसंदीदा में जोड़ें / हटाएँ", "focusSearch": "खोज पर जाएँ", - "toggleFullscreen": "पूर्ण स्क्रीन" + "toggleFullscreen": "पूर्ण स्क्रीन", + "openFiles": "फ़ाइलें इम्पोर्ट किए बिना चलाएँ", + "openFolder": "फ़ोल्डर इम्पोर्ट किए बिना चलाएँ" } }, "offlineMode": { @@ -3275,5 +3284,12 @@ "targetTaken": "{{path}} नाम की फ़ाइल पहले से मौजूद है।" }, "adopts": "यह फ़ाइल लाइब्रेरी में पहले से अलग ट्रैक के रूप में है। वह प्रविष्टि अपने छोटे इतिहास के साथ हटा दी जाती है, और यह ट्रैक उसकी फ़ाइल ले लेता है।" + }, + "openToPlay": { + "files": "फ़ाइलें चलाएँ…", + "folder": "फ़ोल्डर चलाएँ…", + "filesTitle": "चलाने के लिए फ़ाइलें चुनें", + "folderTitle": "चलाने के लिए फ़ोल्डर चुनें", + "filterName": "ऑडियो फ़ाइलें" } } diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 74222b56d..365bc4e20 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" @@ -1579,9 +1580,15 @@ "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": "Folder atau file audio, dipindai lalu dipantau" + } }, "abLoop": { "setA": "Loop A-B: tandai titik A di posisi saat ini", @@ -1873,7 +1880,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": { @@ -3273,5 +3282,12 @@ "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" } } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d6d05e9d5..2ad7adadd 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" @@ -1636,9 +1637,15 @@ "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": "Cartelle o file audio, analizzati e poi monitorati" + } }, "abLoop": { "setA": "Loop A-B: imposta il punto A alla posizione corrente", @@ -1933,7 +1940,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": { @@ -3377,5 +3386,12 @@ "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" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 88be27604..745b3ab2e 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": "プレイリスト" @@ -1579,9 +1580,15 @@ "reasonMissing": "ファイルが見つかりません(削除済み、または接続されていないドライブ上)。次回のスキャンでライブラリが更新されます" }, "dragDrop": { - "dropHint": "ドロップしてインポート", "importing": "インポート中…", - "subtitle": "フォルダまたはオーディオファイルを受け付けます" + "play": { + "title": "今すぐ再生", + "hint": "ライブラリに追加せずに" + }, + "import": { + "title": "ライブラリに追加", + "hint": "フォルダーまたはオーディオファイル。スキャン後に監視します" + } }, "abLoop": { "setA": "A-Bループ:現在位置にA点を設定", @@ -2317,7 +2324,9 @@ "toggleLyrics": "歌詞を表示 / 非表示", "toggleLike": "いいね / 解除", "focusSearch": "検索に移動", - "toggleFullscreen": "全画面表示" + "toggleFullscreen": "全画面表示", + "openFiles": "ファイルをインポートせずに再生", + "openFolder": "フォルダーをインポートせずに再生" } }, "offlineMode": { @@ -3273,5 +3282,12 @@ "targetTaken": "{{path}} という名前のファイルがすでにあります。" }, "adopts": "このファイルはすでに別の曲としてライブラリにあります。その項目は短い履歴ごと削除され、この曲がそのファイルを引き継ぎます。" + }, + "openToPlay": { + "files": "ファイルを再生…", + "folder": "フォルダーを再生…", + "filesTitle": "再生するファイルを選択", + "folderTitle": "再生するフォルダーを選択", + "filterName": "オーディオファイル" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 4cde111b2..33074a60d 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": "재생 목록" @@ -1579,9 +1580,15 @@ "reasonMissing": "파일이 없습니다(삭제되었거나 연결되지 않은 드라이브에 있음). 다음 스캔에서 라이브러리가 업데이트됩니다" }, "dragDrop": { - "dropHint": "여기에 놓아 가져오기", "importing": "가져오는 중…", - "subtitle": "폴더와 오디오 파일을 가져올 수 있습니다" + "play": { + "title": "지금 재생", + "hint": "보관함에 추가하지 않고" + }, + "import": { + "title": "보관함에 추가", + "hint": "폴더 또는 오디오 파일, 스캔한 뒤 계속 확인합니다" + } }, "abLoop": { "setA": "A-B 루프: 현재 위치에 A 지점 설정", @@ -1873,7 +1880,9 @@ "toggleLyrics": "가사 표시 / 숨기기", "toggleLike": "좋아요 / 좋아요 취소", "focusSearch": "검색으로 이동", - "toggleFullscreen": "전체 화면" + "toggleFullscreen": "전체 화면", + "openFiles": "파일을 가져오지 않고 재생", + "openFolder": "폴더를 가져오지 않고 재생" } }, "offlineMode": { @@ -3273,5 +3282,12 @@ "targetTaken": "다른 파일이 이미 {{path}} 이름을 쓰고 있습니다." }, "adopts": "이 파일은 이미 라이브러리에 별도의 곡으로 있습니다. 그 항목은 짧은 기록과 함께 삭제되고, 이 곡이 그 파일을 이어받습니다." + }, + "openToPlay": { + "files": "파일 재생…", + "folder": "폴더 재생…", + "filesTitle": "재생할 파일 선택", + "folderTitle": "재생할 폴더 선택", + "filterName": "오디오 파일" } } diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 40e2f5807..8026c5fe9 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" @@ -1581,9 +1582,15 @@ "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": "Mappen of audiobestanden, gescand en daarna gevolgd" + } }, "abLoop": { "setA": "A-B-loop: punt A op huidige positie instellen", @@ -1875,7 +1882,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": { @@ -3275,5 +3284,12 @@ "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" } } diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 4a3a9ba4e..80794926b 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" @@ -1637,9 +1638,15 @@ "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": "Pastas ou arquivos de áudio, analisados e depois monitorados" + } }, "abLoop": { "setA": "Loop A-B: definir o ponto A na posição atual", @@ -1934,7 +1941,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": { @@ -3378,5 +3387,12 @@ "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" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index c497cd30d..fcae8ec1d 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" @@ -1637,9 +1638,15 @@ "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": "Pastas ou ficheiros de áudio, analisados e depois acompanhados" + } }, "abLoop": { "setA": "Ciclo A-B: definir o ponto A na posição atual", @@ -1934,7 +1941,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": { @@ -3378,5 +3387,12 @@ "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": "Escolha os ficheiros a reproduzir", + "folderTitle": "Escolha a pasta a reproduzir", + "filterName": "Ficheiros de áudio" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 00106c086..111efe942 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": "Плейлисты" @@ -1695,9 +1696,15 @@ "reasonMissing": "файла нет на месте (удалён или на неподключённом диске); следующее сканирование обновит медиатеку" }, "dragDrop": { - "dropHint": "Отпустите, чтобы импортировать", "importing": "Импорт…", - "subtitle": "Принимаются папки или аудиофайлы" + "play": { + "title": "Воспроизвести сейчас", + "hint": "Не добавляя в библиотеку" + }, + "import": { + "title": "Добавить в библиотеку", + "hint": "Папки или аудиофайлы: сканируются, затем отслеживаются" + } }, "abLoop": { "setA": "A-B-повтор: установить точку A на текущей позиции", @@ -1995,7 +2002,9 @@ "toggleLyrics": "Показать/скрыть панель текста", "toggleLike": "Добавить в любимое / Убрать из любимого", "focusSearch": "Перейти к поиску", - "toggleFullscreen": "Полноэкранный режим" + "toggleFullscreen": "Полноэкранный режим", + "openFiles": "Воспроизвести файлы без импорта", + "openFolder": "Воспроизвести папку без импорта" } }, "offlineMode": { @@ -3483,5 +3492,12 @@ "targetTaken": "Другой файл уже называется {{path}}." }, "adopts": "Этот файл уже есть в медиатеке как отдельный трек. Та запись удаляется вместе с её короткой историей, а этот трек забирает её файл." + }, + "openToPlay": { + "files": "Воспроизвести файлы…", + "folder": "Воспроизвести папку…", + "filesTitle": "Выберите файлы для воспроизведения", + "folderTitle": "Выберите папку для воспроизведения", + "filterName": "Аудиофайлы" } } diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 398cf9f95..aaa1554ce 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" @@ -1581,9 +1582,15 @@ "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": "Klasörler veya ses dosyaları; taranır, sonra izlenir" + } }, "abLoop": { "setA": "A-B döngüsü: A noktasını mevcut konuma ayarla", @@ -1875,7 +1882,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": { @@ -3275,5 +3284,12 @@ "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ı" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3e6dd558d..3dabce067 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": "播放列表" @@ -1579,9 +1580,15 @@ "reasonMissing": "文件不在原处(已删除,或位于未连接的驱动器上);下次扫描会更新音乐库" }, "dragDrop": { - "dropHint": "松开以导入", "importing": "正在导入…", - "subtitle": "支持文件夹或音频文件" + "play": { + "title": "立即播放", + "hint": "不添加到音乐库" + }, + "import": { + "title": "添加到音乐库", + "hint": "文件夹或音频文件,扫描后持续监视" + } }, "abLoop": { "setA": "A-B 循环:在当前位置设置 A 点", @@ -2317,7 +2324,9 @@ "toggleLyrics": "显示/隐藏歌词", "toggleLike": "喜欢/取消喜欢", "focusSearch": "跳转到搜索", - "toggleFullscreen": "全屏" + "toggleFullscreen": "全屏", + "openFiles": "不导入直接播放文件", + "openFolder": "不导入直接播放文件夹" } }, "offlineMode": { @@ -3273,5 +3282,12 @@ "targetTaken": "已有另一个文件名为 {{path}}。" }, "adopts": "此文件已作为单独的曲目在音乐库中。该条目及其简短的历史记录将被删除,由这首曲目接管其文件。" + }, + "openToPlay": { + "files": "播放文件…", + "folder": "播放文件夹…", + "filesTitle": "选择要播放的文件", + "folderTitle": "选择要播放的文件夹", + "filterName": "音频文件" } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 47d06fd93..5edf66cc0 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": "播放清單" @@ -1579,9 +1580,15 @@ "reasonMissing": "檔案不在原處(已刪除,或位於未連接的磁碟機上);下次掃描會更新音樂庫" }, "dragDrop": { - "dropHint": "拖放至此以匯入", "importing": "正在匯入…", - "subtitle": "接受資料夾或音訊檔案" + "play": { + "title": "立即播放", + "hint": "不加入音樂庫" + }, + "import": { + "title": "加入音樂庫", + "hint": "資料夾或音訊檔,掃描後持續監看" + } }, "abLoop": { "setA": "A-B 循環:在目前位置設定 A 點", @@ -2317,7 +2324,9 @@ "toggleLyrics": "顯示 / 隱藏歌詞", "toggleLike": "收藏 / 取消收藏", "focusSearch": "跳至搜尋", - "toggleFullscreen": "全螢幕" + "toggleFullscreen": "全螢幕", + "openFiles": "不匯入直接播放檔案", + "openFolder": "不匯入直接播放資料夾" } }, "offlineMode": { @@ -3273,5 +3282,12 @@ "targetTaken": "已有另一個檔案名為 {{path}}。" }, "adopts": "此檔案已作為單獨的曲目在音樂庫中。該項目及其簡短的歷史紀錄將被刪除,由這首曲目接手其檔案。" + }, + "openToPlay": { + "files": "播放檔案…", + "folder": "播放資料夾…", + "filesTitle": "選擇要播放的檔案", + "folderTitle": "選擇要播放的資料夾", + "filterName": "音訊檔" } } diff --git a/src/lib/openToPlay.ts b/src/lib/openToPlay.ts new file mode 100644 index 000000000..35107d9ed --- /dev/null +++ b/src/lib/openToPlay.ts @@ -0,0 +1,69 @@ +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", +]; + +/** + * 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`). + */ +export async function playWithoutImporting(paths: string[]): Promise { + if (paths.length === 0) return; + try { + await playerPlayPaths(paths); + } catch (err) { + console.error("[openToPlay] could not play", err); + } +} + +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 open({ + multiple: true, + directory: false, + title: i18n.t("openToPlay.filesTitle"), + filters: [ + { + name: i18n.t("openToPlay.filterName"), + extensions: PLAYABLE_EXTENSIONS, + }, + ], + }); + await playWithoutImporting(asList(picked)); +} + +/** Pick a folder and play the audio files in it without importing them. */ +export async function openFolderToPlay(): Promise { + const picked = await open({ + multiple: false, + directory: true, + title: i18n.t("openToPlay.folderTitle"), + }); + await playWithoutImporting(asList(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/styles/skins/fluid.css b/src/styles/skins/fluid.css index 7b0808ffc..396e420e2 100644 --- a/src/styles/skins/fluid.css +++ b/src/styles/skins/fluid.css @@ -2258,6 +2258,91 @@ 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); +} +:root[data-layout="fluid"] [data-drop-status] { + border-radius: 0.875rem; + background-color: var(--fe-surface-2); + box-shadow: + inset 0 1px 0 var(--fe-hairline-strong), + 0 24px 60px -24px rgb(0 0 0 / 0.6); +} +:root[data-layout="fluid"] [data-drop-status] > svg { + color: var(--fe-accent); +} +:root[data-layout="fluid"] [data-drop-status] > div { + color: var(--fe-ink); + font-weight: 540; +} + /* ── Reduced motion: no drift, no stagger, no lift ─────────── */ @media (prefers-reduced-motion: reduce) { :root[data-layout="fluid"] .fe-rise, @@ -2279,7 +2364,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 +2375,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; } } From 53b2336b9bc9649f40237ca2fe7fbe719ef35eb7 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 07:20:02 +0200 Subject: [PATCH 2/6] fix(ui): let an import's scan report in the status bar instead of a full-screen overlay Dropping a whole music folder kept a blurred importing screen over the app for the minutes its scan took, hiding the task status bar that already shows and stops that scan. --- docs/features/library.md | 2 +- src/components/layout/AppLayout.tsx | 5 ++- src/components/layout/DropTargetsOverlay.tsx | 32 +++++--------------- src/hooks/useDragDropImport.ts | 15 +++++---- src/i18n/locales/ar.json | 1 - src/i18n/locales/de.json | 1 - src/i18n/locales/en.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/hi.json | 1 - src/i18n/locales/id.json | 1 - src/i18n/locales/it.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/ko.json | 1 - src/i18n/locales/nl.json | 1 - src/i18n/locales/pt-BR.json | 1 - src/i18n/locales/pt.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/tr.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-TW.json | 1 - src/styles/skins/fluid.css | 14 --------- 22 files changed, 17 insertions(+), 68 deletions(-) diff --git a/docs/features/library.md b/docs/features/library.md index be6d88b3a..d70f39ca8 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -249,7 +249,7 @@ Auto-creates a default library on the very first drop when the profile has none, **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 shows a spinner while an import's scan runs. Its base styling works in every skin; the Fluid skin recomposes it through `data-drop-*` landmarks (surface cards, the accent ring and the wave mark under the target being 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/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 8292ce81b..1df16dedd 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -755,11 +755,10 @@ export function AppLayout() { )} {/* Drag-and-drop: two targets while files are dragged over the window — play them now without importing them, or add them to - the library — then an "importing…" state while the scan runs. */} - {(dragDrop.isDraggingOver || dragDrop.isImporting) && ( + the library. An import's scan then reports in the status bar. */} + {dragDrop.isDraggingOver && ( )} diff --git a/src/components/layout/DropTargetsOverlay.tsx b/src/components/layout/DropTargetsOverlay.tsx index 49e82001c..f2933bf52 100644 --- a/src/components/layout/DropTargetsOverlay.tsx +++ b/src/components/layout/DropTargetsOverlay.tsx @@ -1,7 +1,7 @@ import type { ReactNode, RefObject } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { FolderPlus, Loader2, Play } from "lucide-react"; +import { FolderPlus, Play } from "lucide-react"; import type { DropTarget, DropTargetElements, @@ -15,40 +15,22 @@ import type { * * Portalled to `document.body`, above every skin's stacking contexts. * Pointer-events are off so the drop still reaches Tauri's native handler - * underneath. Base styling works in every skin; the Fluid skin recomposes - * it through the `data-drop-*` landmarks. + * 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, - isImporting, targetsRef, }: { hoveredTarget: DropTarget | null; - isImporting: boolean; targetsRef: RefObject; }) { const { t } = useTranslation(); - if (isImporting) { - return createPortal( -
-
- -
- {t("dragDrop.importing")} -
-
-
, - document.body, - ); - } - return createPortal(
>; +export type DropTargetElements = Partial< + Record +>; /** * The target under `point` (CSS pixels), or the nearer one when the point @@ -67,7 +69,6 @@ function targetAt( */ export function useDragDropImport(): { isDraggingOver: boolean; - isImporting: boolean; lastError: string | null; /** The target the pointer is over while dragging. */ hoveredTarget: DropTarget | null; @@ -82,14 +83,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 handleImport = useCallback( async (paths: string[]) => { - setIsImporting(true); setLastError(null); try { // Resolve a target library — auto-create one if the profile @@ -100,7 +99,9 @@ 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); } @@ -110,8 +111,6 @@ export function useDragDropImport(): { } catch (err) { console.error("[useDragDropImport] import failed", err); setLastError(String(err)); - } finally { - setIsImporting(false); } }, [libraries, selectedLibraryId, selectLibrary, createLibrary, refresh, t], @@ -178,5 +177,5 @@ export function useDragDropImport(): { }; }, [handleImport]); - return { isDraggingOver, isImporting, lastError, hoveredTarget, targetsRef }; + return { isDraggingOver, lastError, hoveredTarget, targetsRef }; } diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 9d0a6e0a6..d9544fa6d 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1770,7 +1770,6 @@ "reasonMissing": "الملف غير موجود (محذوف، أو على محرك غير متصل)؛ سيحدّث الفحص التالي المكتبة" }, "dragDrop": { - "importing": "جارٍ الاستيراد…", "play": { "title": "تشغيل الآن", "hint": "دون إضافته إلى المكتبة" diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 1dde64cdd..213a7ed6a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1582,7 +1582,6 @@ "reasonMissing": "die Datei ist nicht da (gelöscht oder auf einem nicht verbundenen Laufwerk); der nächste Scan aktualisiert die Mediathek" }, "dragDrop": { - "importing": "Wird importiert…", "play": { "title": "Jetzt abspielen", "hint": "Ohne es zur Bibliothek hinzuzufügen" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9f7a8b244..ddbbbaf28 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1582,7 +1582,6 @@ "reasonMissing": "the file is not there (deleted, or on a drive that is not connected); the next scan updates the library" }, "dragDrop": { - "importing": "Importing…", "play": { "title": "Play now", "hint": "Without adding it to the library" diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0dcfa779a..1d3b08a98 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1637,7 +1637,6 @@ "reasonMissing": "el archivo no está (eliminado o en una unidad no conectada); el próximo escaneo actualizará la biblioteca" }, "dragDrop": { - "importing": "Importando…", "play": { "title": "Reproducir ahora", "hint": "Sin añadirlo a la biblioteca" diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4ad8907c1..3dcd30019 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1757,7 +1757,6 @@ "reasonMissing": "le fichier est introuvable (supprimé, ou sur un disque non connecté) ; le prochain scan mettra la bibliothèque à jour" }, "dragDrop": { - "importing": "Import en cours…", "play": { "title": "Lire maintenant", "hint": "Sans l'ajouter à la bibliothèque" diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index a4484073b..96f3e9e14 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1582,7 +1582,6 @@ "reasonMissing": "फ़ाइल वहाँ नहीं है (हटाई गई, या ऐसी ड्राइव पर जो जुड़ी नहीं है); अगला स्कैन लाइब्रेरी अपडेट करेगा" }, "dragDrop": { - "importing": "आयात हो रहा है…", "play": { "title": "अभी चलाएँ", "hint": "लाइब्रेरी में जोड़े बिना" diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 365bc4e20..412f924f7 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1580,7 +1580,6 @@ "reasonMissing": "file tidak ada (dihapus, atau di drive yang tidak terhubung); pemindaian berikutnya memperbarui pustaka" }, "dragDrop": { - "importing": "Mengimpor…", "play": { "title": "Putar sekarang", "hint": "Tanpa menambahkannya ke pustaka" diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 2ad7adadd..5b899d947 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1637,7 +1637,6 @@ "reasonMissing": "il file non c'è (eliminato o su un'unità non collegata); la prossima scansione aggiornerà la libreria" }, "dragDrop": { - "importing": "Importazione…", "play": { "title": "Riproduci ora", "hint": "Senza aggiungerlo alla libreria" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 745b3ab2e..81000e064 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1580,7 +1580,6 @@ "reasonMissing": "ファイルが見つかりません(削除済み、または接続されていないドライブ上)。次回のスキャンでライブラリが更新されます" }, "dragDrop": { - "importing": "インポート中…", "play": { "title": "今すぐ再生", "hint": "ライブラリに追加せずに" diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 33074a60d..6f233e229 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1580,7 +1580,6 @@ "reasonMissing": "파일이 없습니다(삭제되었거나 연결되지 않은 드라이브에 있음). 다음 스캔에서 라이브러리가 업데이트됩니다" }, "dragDrop": { - "importing": "가져오는 중…", "play": { "title": "지금 재생", "hint": "보관함에 추가하지 않고" diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 8026c5fe9..1868ba51b 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1582,7 +1582,6 @@ "reasonMissing": "het bestand is er niet (verwijderd of op een niet-aangesloten schijf); de volgende scan werkt de bibliotheek bij" }, "dragDrop": { - "importing": "Importeren…", "play": { "title": "Nu afspelen", "hint": "Zonder het aan de bibliotheek toe te voegen" diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 80794926b..a087ae497 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1638,7 +1638,6 @@ "reasonMissing": "o arquivo não existe (excluído ou em uma unidade não conectada); a próxima varredura atualiza a biblioteca" }, "dragDrop": { - "importing": "Importando…", "play": { "title": "Tocar agora", "hint": "Sem adicioná-lo à biblioteca" diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index fcae8ec1d..5c938b50a 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1638,7 +1638,6 @@ "reasonMissing": "o ficheiro não existe (eliminado ou numa unidade não ligada); a próxima análise atualiza a biblioteca" }, "dragDrop": { - "importing": "A importar…", "play": { "title": "Reproduzir agora", "hint": "Sem o adicionar à biblioteca" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 111efe942..074a0dc73 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1696,7 +1696,6 @@ "reasonMissing": "файла нет на месте (удалён или на неподключённом диске); следующее сканирование обновит медиатеку" }, "dragDrop": { - "importing": "Импорт…", "play": { "title": "Воспроизвести сейчас", "hint": "Не добавляя в библиотеку" diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index aaa1554ce..aa87d38b6 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1582,7 +1582,6 @@ "reasonMissing": "dosya yerinde değil (silinmiş ya da bağlı olmayan bir sürücüde); sonraki tarama kitaplığı güncelleyecek" }, "dragDrop": { - "importing": "İçe aktarılıyor…", "play": { "title": "Şimdi çal", "hint": "Kitaplığa eklemeden" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3dabce067..0fb1e5fb8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1580,7 +1580,6 @@ "reasonMissing": "文件不在原处(已删除,或位于未连接的驱动器上);下次扫描会更新音乐库" }, "dragDrop": { - "importing": "正在导入…", "play": { "title": "立即播放", "hint": "不添加到音乐库" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5edf66cc0..8bf6d953c 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1580,7 +1580,6 @@ "reasonMissing": "檔案不在原處(已刪除,或位於未連接的磁碟機上);下次掃描會更新音樂庫" }, "dragDrop": { - "importing": "正在匯入…", "play": { "title": "立即播放", "hint": "不加入音樂庫" diff --git a/src/styles/skins/fluid.css b/src/styles/skins/fluid.css index 396e420e2..b86f3b4c6 100644 --- a/src/styles/skins/fluid.css +++ b/src/styles/skins/fluid.css @@ -2328,20 +2328,6 @@ font-size: 0.9375rem; color: var(--fe-ink-2); } -:root[data-layout="fluid"] [data-drop-status] { - border-radius: 0.875rem; - background-color: var(--fe-surface-2); - box-shadow: - inset 0 1px 0 var(--fe-hairline-strong), - 0 24px 60px -24px rgb(0 0 0 / 0.6); -} -:root[data-layout="fluid"] [data-drop-status] > svg { - color: var(--fe-accent); -} -:root[data-layout="fluid"] [data-drop-status] > div { - color: var(--fe-ink); - font-weight: 540; -} /* ── Reduced motion: no drift, no stagger, no lift ─────────── */ @media (prefers-reduced-motion: reduce) { From 0c4e24a6f11998064d48858b6c675182dd07536f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 07:59:35 +0200 Subject: [PATCH 3/6] feat(library): copy dropped files into a managed folder and refuse overlapping library folders A drop on add to the library used to register the folder the files came from, so dropping one file from Music added all of it. The files are now copied into WaveFlow under the user's music folder, which is the library folder; items already in the library are passed over and folders holding one refused. Adding a folder that is, lies under or holds a library folder is refused with a toast naming the folder in the way. --- CLAUDE.md | 1 + docs/architecture/invariants.md | 6 + docs/features/library.md | 16 +- src-tauri/crates/app/src/commands/library.rs | 200 +++++---- src-tauri/crates/app/src/commands/scan.rs | 2 +- src-tauri/crates/app/src/drop_import.rs | 417 +++++++++++++++++++ src-tauri/crates/app/src/error.rs | 24 ++ src-tauri/crates/app/src/lib.rs | 3 +- src-tauri/crates/app/src/tasks.rs | 7 +- src-tauri/crates/core/src/scanner/folders.rs | 123 ++++++ src-tauri/crates/core/src/scanner/mod.rs | 1 + src/components/common/ImportNoticeToast.tsx | 160 +++++++ src/components/layout/AppLayout.tsx | 2 + src/contexts/LibraryContext.tsx | 13 +- src/hooks/useDragDropImport.ts | 16 +- src/i18n/locales/ar.json | 24 +- src/i18n/locales/de.json | 24 +- src/i18n/locales/en.json | 24 +- src/i18n/locales/es.json | 24 +- src/i18n/locales/fr.json | 24 +- src/i18n/locales/hi.json | 24 +- src/i18n/locales/id.json | 24 +- src/i18n/locales/it.json | 24 +- src/i18n/locales/ja.json | 24 +- src/i18n/locales/ko.json | 24 +- src/i18n/locales/nl.json | 24 +- src/i18n/locales/pt-BR.json | 24 +- src/i18n/locales/pt.json | 28 +- src/i18n/locales/ru.json | 24 +- src/i18n/locales/tr.json | 24 +- src/i18n/locales/zh-CN.json | 24 +- src/i18n/locales/zh-TW.json | 24 +- src/lib/tauri/library.ts | 91 +++- 33 files changed, 1356 insertions(+), 138 deletions(-) create mode 100644 src-tauri/crates/app/src/drop_import.rs create mode 100644 src-tauri/crates/core/src/scanner/folders.rs create mode 100644 src/components/common/ImportNoticeToast.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 00a8cc506..64bafe0fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,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 d70f39ca8..dd693a312 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,9 +241,21 @@ 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::relation`](../../src-tauri/crates/core/src/scanner/folders.rs)): `.` 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, `.lrc`, `.ttml`); a dropped file goes to the root, with its same-name `.lrc`. 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. 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 and 200 000 entries walked per drop; past that the drop is cut short and says so. The copy is a `drop_import` task in the status bar, stopped by its flag between two files; what was copied is still scanned. + +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. diff --git a/src-tauri/crates/app/src/commands/library.rs b/src-tauri/crates/app/src/commands/library.rs index 17f19fd10..7002dbd06 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)) = crate::drop_import::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, Copied, 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,98 @@ 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 = + drop_import::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 || { + let plan = drop_import::plan(&dropped, ©_managed, &library); + let mut out = DropImportSummary { + in_library: plan.in_library, + refused: plan.refused, + truncated: plan.truncated, + ..Default::default() }; - let canonical = folder.to_string_lossy().to_string(); - if seen.insert(canonical.clone()) { - folder_paths.push(canonical); + if 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; + // The registry's own flag stops the copy, as it does a scan. + let total = plan.copies.len() as u64; + let task = crate::tasks::start( + ©_app, + crate::tasks::TaskKind::DropImport, + total, + crate::tasks::Cancellation::Flag, + ); + for (done, copy) in plan.copies.iter().enumerate() { + if task.as_ref().is_some_and(|t| t.is_cancelling()) { + out.cancelled = true; + break; + } + 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()); } } - Err(err) => { - tracing::warn!(folder_id, path = %path, %err, "import_paths: scan failed"); - total.errors += 1; + match drop_import::copy_one(©.from, ©.to) { + Ok(Copied::New(_)) => out.copied += 1, + Ok(Copied::AlreadyThere) => out.already_copied += 1, + Err(err) => { + tracing::warn!(from = %copy.from.display(), %err, "drop import: copy failed"); + out.errors += 1; + } } } + 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..155afdff7 --- /dev/null +++ b/src-tauri/crates/app/src/drop_import.rs @@ -0,0 +1,417 @@ +//! 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 `.lrc`. +//! - **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::path::{Path, PathBuf}; + +use serde::Serialize; +use waveflow_core::scanner::folders::{relation, FolderRelation}; + +use crate::commands::scan::ScanSummary; + +/// The managed folder's name under the user's music folder. +pub const MANAGED_FOLDER_NAME: &str = "WaveFlow"; + +/// Files beside the audio that a dropped folder brings along: the app reads +/// folder covers and lyrics sidecars from them. +const SIDECAR_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "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; + +/// 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, + /// Files that could not be read or written. + 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)) +} + +/// The library folder that will index the managed folder's files: the +/// managed folder's own row, else the deepest library folder holding it (a +/// user whose library is the whole music folder already scans it). `None` +/// when neither exists and the managed folder must be added. +/// +/// A library folder *inside* the managed folder is an overlap the drop +/// 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 = path.components().count(); + // 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 library folder `candidate` overlaps, if any, and how: the same folder +/// first, then one holding it, then one under it. +pub fn overlap<'a>( + candidate: &Path, + folders: impl IntoIterator, +) -> Option<(FolderRelation, PathBuf)> { + let mut found: Option<(FolderRelation, PathBuf)> = None; + for folder in folders { + let Some(rel) = relation(candidate, folder) else { + continue; + }; + let rank = |r: FolderRelation| match r { + FolderRelation::Same => 0, + FolderRelation::Inside => 1, + FolderRelation::Contains => 2, + }; + let closer = match &found { + Some((kept, _)) => rank(rel) < rank(*kept), + None => true, + }; + if closer { + found = Some((rel, folder.to_path_buf())); + } + } + found +} + +/// One file to copy, and where. +#[derive(Debug, PartialEq, Eq)] +pub struct Copy { + pub from: PathBuf, + pub to: PathBuf, +} + +/// 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, + pub truncated: bool, +} + +/// What to copy for `dropped` into `managed`, given the library folders +/// (the managed folder counts as one). Blocking: walks dropped folders. +pub fn plan(dropped: &[PathBuf], managed: &Path, library: &[PathBuf]) -> Plan { + let mut out = Plan::default(); + let mut visited = 0usize; + let folders = || library.iter().map(PathBuf::as_path).chain([managed]); + for item in dropped { + 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() { + let base = managed.join(item.file_name().unwrap_or_else(|| "Import".as_ref())); + let walk = walkdir::WalkDir::new(item) + .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()) + }); + for entry in walk.flatten() { + visited += 1; + if visited >= MAX_VISITED || out.copies.len() >= MAX_FILES { + out.truncated = true; + return out; + } + if !entry.file_type().is_file() + || !(is_audio(entry.path()) || is_sidecar(entry.path())) + { + continue; + } + let Ok(rel) = entry.path().strip_prefix(item) else { + continue; + }; + out.copies.push(Copy { + to: base.join(rel), + from: entry.into_path(), + }); + } + } 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), + }); + let lyrics = item.with_extension("lrc"); + if lyrics.is_file() { + if let Some(name) = lyrics.file_name() { + out.copies.push(Copy { + to: managed.join(name), + from: lyrics, + }); + } + } + } + } + out +} + +fn is_audio(path: &Path) -> bool { + waveflow_core::scanner::extract::is_scannable_audio(path) +} + +fn is_sidecar(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + SIDECAR_EXTENSIONS + .iter() + .any(|s| ext.eq_ignore_ascii_case(s)) + }) +} + +/// 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. + AlreadyThere, +} + +/// 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), + 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), + } + } + 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(Copied::New(target)) +} + +/// `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(); + } + + #[test] + fn a_folder_keeps_its_tree_and_brings_covers_and_lyrics() { + 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("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, &[]); + let mut to: Vec<_> = plan.copies.iter().map(|c| c.to.clone()).collect(); + to.sort(); + let base = managed.join("Album"); + assert_eq!( + to, + vec![ + base.join("01.flac"), + base.join("01.lrc"), + base.join("CD2").join("01.mp3"), + base.join("cover.jpg"), + ] + ); + } + + #[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, &[]); + let to: Vec<_> = plan.copies.iter().map(|c| c.to.clone()).collect(); + assert_eq!(to, vec![managed.join("song.mp3"), managed.join("song.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)); + assert!(plan.copies.is_empty()); + assert_eq!(plan.in_library, 2); + assert_eq!(plan.refused, 1); + } + + #[test] + fn the_managed_folder_is_indexed_by_its_row_or_the_folder_holding_it() { + let managed = PathBuf::from("/m/Music/WaveFlow"); + let music = (1, PathBuf::from("/m/Music")); + let own = (2, managed.clone()); + let other = (3, PathBuf::from("/m/Downloads")); + assert_eq!( + indexing_folder(&managed, std::slice::from_ref(&other)), + Ok(None) + ); + assert_eq!( + indexing_folder(&managed, &[music.clone(), other.clone()]), + Ok(Some(1)) + ); + assert_eq!(indexing_folder(&managed, &[music, own]), Ok(Some(2))); + let under = (4, managed.join("Album")); + assert_eq!( + indexing_folder(&managed, std::slice::from_ref(&under)), + Err((FolderRelation::Contains, under.1)) + ); + } + + #[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); + + 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()); + } +} 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/folders.rs b/src-tauri/crates/core/src/scanner/folders.rs new file mode 100644 index 000000000..7d8ae1d9c --- /dev/null +++ b/src-tauri/crates/core/src/scanner/folders.rs @@ -0,0 +1,123 @@ +//! 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}; + +/// 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 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::*; + use std::path::PathBuf; + + 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) + ); + } + + #[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/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 1df16dedd..82546e6c6 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -38,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"; @@ -923,6 +924,7 @@ export function AppLayout() { + 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 5bde2c6c5..612ec5006 100644 --- a/src/hooks/useDragDropImport.ts +++ b/src/hooks/useDragDropImport.ts @@ -2,7 +2,11 @@ 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. */ @@ -60,8 +64,9 @@ function targetAt( /** * 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_paths` (a mix of folders and audio files, files contributing - * their parent directory). + * `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. * * The overlay registers where the targets are in `targetsRef`; the hook * follows the pointer across them and reports the one under it, which is @@ -106,10 +111,13 @@ export function useDragDropImport(): { 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)); } }, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index d9544fa6d..581ec35bb 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1776,7 +1776,7 @@ }, "import": { "title": "إضافة إلى المكتبة", - "hint": "مجلدات أو ملفات صوتية، تُفحص ثم تُتابَع" + "hint": "تُنسخ إلى مجلد WaveFlow في موسيقاك" } }, "abLoop": { @@ -3474,7 +3474,8 @@ "reconcile": "المطابقة", "upload": "الرفع", "backup": "النسخ الاحتياطي", - "thumbnails": "الصور المصغّرة" + "thumbnails": "الصور المصغّرة", + "drop_import": "النسخ إلى المكتبة" }, "more_zero": "لا مهام أخرى", "more_two": "مهمتان أخريان", @@ -3661,5 +3662,24 @@ "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 213a7ed6a..15e4a6a86 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1588,7 +1588,7 @@ }, "import": { "title": "Zur Bibliothek hinzufügen", - "hint": "Ordner oder Audiodateien, eingelesen und danach überwacht" + "hint": "In den WaveFlow-Ordner deiner Musik kopiert" } }, "abLoop": { @@ -3147,7 +3147,8 @@ "reconcile": "Abgleich", "upload": "Hochladen", "backup": "Sicherung", - "thumbnails": "Vorschaubilder" + "thumbnails": "Vorschaubilder", + "drop_import": "Kopieren in die Bibliothek" } }, "smartRuleSummary": { @@ -3290,5 +3291,24 @@ "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 ddbbbaf28..0c6da3fd3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1588,7 +1588,7 @@ }, "import": { "title": "Add to the library", - "hint": "Folders or audio files, scanned and then watched" + "hint": "Copied into the WaveFlow folder of your music" } }, "abLoop": { @@ -3147,7 +3147,8 @@ "reconcile": "Reconciliation", "upload": "Upload", "backup": "Backup", - "thumbnails": "Thumbnails" + "thumbnails": "Thumbnails", + "drop_import": "Copying to the library" } }, "smartRuleSummary": { @@ -3290,5 +3291,24 @@ "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 1d3b08a98..53123c383 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1643,7 +1643,7 @@ }, "import": { "title": "Añadir a la biblioteca", - "hint": "Carpetas o archivos de audio, analizados y luego vigilados" + "hint": "Copiados en la carpeta WaveFlow de tu música" } }, "abLoop": { @@ -3239,7 +3239,8 @@ "reconcile": "Reconciliación", "upload": "Subida", "backup": "Copia de seguridad", - "thumbnails": "Miniaturas" + "thumbnails": "Miniaturas", + "drop_import": "Copia a la biblioteca" } }, "smartRuleSummary": { @@ -3392,5 +3393,24 @@ "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 3dcd30019..2897b7117 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1763,7 +1763,7 @@ }, "import": { "title": "Ajouter à la bibliothèque", - "hint": "Dossiers ou fichiers audio, analysés puis suivis" + "hint": "Copiés dans le dossier WaveFlow de votre musique" } }, "abLoop": { @@ -3239,7 +3239,8 @@ "reconcile": "Réconciliation", "upload": "Téléversement", "backup": "Sauvegarde", - "thumbnails": "Vignettes" + "thumbnails": "Vignettes", + "drop_import": "Copie vers la bibliothèque" } }, "smartRuleSummary": { @@ -3392,5 +3393,24 @@ "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 96f3e9e14..07f3cfd0e 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1588,7 +1588,7 @@ }, "import": { "title": "लाइब्रेरी में जोड़ें", - "hint": "फ़ोल्डर या ऑडियो फ़ाइलें, स्कैन होकर फिर निगरानी में" + "hint": "आपके संगीत के WaveFlow फ़ोल्डर में कॉपी" } }, "abLoop": { @@ -3147,7 +3147,8 @@ "reconcile": "मिलान", "upload": "अपलोड", "backup": "बैकअप", - "thumbnails": "थंबनेल" + "thumbnails": "थंबनेल", + "drop_import": "लाइब्रेरी में कॉपी" } }, "smartRuleSummary": { @@ -3290,5 +3291,24 @@ "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 412f924f7..c7ca7ddbc 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1586,7 +1586,7 @@ }, "import": { "title": "Tambahkan ke pustaka", - "hint": "Folder atau file audio, dipindai lalu dipantau" + "hint": "Disalin ke folder WaveFlow di musik Anda" } }, "abLoop": { @@ -3145,7 +3145,8 @@ "reconcile": "Rekonsiliasi", "upload": "Unggah", "backup": "Cadangan", - "thumbnails": "Gambar mini" + "thumbnails": "Gambar mini", + "drop_import": "Menyalin ke pustaka" } }, "smartRuleSummary": { @@ -3288,5 +3289,24 @@ "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 5b899d947..ba22957a7 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1643,7 +1643,7 @@ }, "import": { "title": "Aggiungi alla libreria", - "hint": "Cartelle o file audio, analizzati e poi monitorati" + "hint": "Copiati nella cartella WaveFlow della tua musica" } }, "abLoop": { @@ -3239,7 +3239,8 @@ "reconcile": "Riconciliazione", "upload": "Caricamento", "backup": "Backup", - "thumbnails": "Miniature" + "thumbnails": "Miniature", + "drop_import": "Copia nella libreria" } }, "smartRuleSummary": { @@ -3392,5 +3393,24 @@ "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 81000e064..78bba895c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1586,7 +1586,7 @@ }, "import": { "title": "ライブラリに追加", - "hint": "フォルダーまたはオーディオファイル。スキャン後に監視します" + "hint": "ミュージックの WaveFlow フォルダーにコピー" } }, "abLoop": { @@ -3145,7 +3145,8 @@ "reconcile": "照合", "upload": "アップロード", "backup": "バックアップ", - "thumbnails": "サムネイル" + "thumbnails": "サムネイル", + "drop_import": "ライブラリへのコピー" } }, "smartRuleSummary": { @@ -3288,5 +3289,24 @@ "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 6f233e229..96bdd1cb1 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1586,7 +1586,7 @@ }, "import": { "title": "보관함에 추가", - "hint": "폴더 또는 오디오 파일, 스캔한 뒤 계속 확인합니다" + "hint": "음악의 WaveFlow 폴더로 복사됨" } }, "abLoop": { @@ -3145,7 +3145,8 @@ "reconcile": "대조", "upload": "업로드", "backup": "백업", - "thumbnails": "썸네일" + "thumbnails": "썸네일", + "drop_import": "보관함으로 복사" } }, "smartRuleSummary": { @@ -3288,5 +3289,24 @@ "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 1868ba51b..100a83867 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1588,7 +1588,7 @@ }, "import": { "title": "Aan de bibliotheek toevoegen", - "hint": "Mappen of audiobestanden, gescand en daarna gevolgd" + "hint": "Gekopieerd naar de WaveFlow-map van je muziek" } }, "abLoop": { @@ -3147,7 +3147,8 @@ "reconcile": "Afstemming", "upload": "Uploaden", "backup": "Back-up", - "thumbnails": "Miniaturen" + "thumbnails": "Miniaturen", + "drop_import": "Kopiëren naar de bibliotheek" } }, "smartRuleSummary": { @@ -3290,5 +3291,24 @@ "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 a087ae497..fd961bbfa 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1644,7 +1644,7 @@ }, "import": { "title": "Adicionar à biblioteca", - "hint": "Pastas ou arquivos de áudio, analisados e depois monitorados" + "hint": "Copiados para a pasta WaveFlow da sua música" } }, "abLoop": { @@ -3240,7 +3240,8 @@ "reconcile": "Reconciliação", "upload": "Envio", "backup": "Backup", - "thumbnails": "Miniaturas" + "thumbnails": "Miniaturas", + "drop_import": "Cópia para a biblioteca" } }, "smartRuleSummary": { @@ -3393,5 +3394,24 @@ "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 5c938b50a..8a745d29d 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1644,7 +1644,7 @@ }, "import": { "title": "Adicionar à biblioteca", - "hint": "Pastas ou ficheiros de áudio, analisados e depois acompanhados" + "hint": "Copiados para a pasta WaveFlow da tua música" } }, "abLoop": { @@ -3240,7 +3240,8 @@ "reconcile": "Reconciliação", "upload": "Envio", "backup": "Cópia de segurança", - "thumbnails": "Miniaturas" + "thumbnails": "Miniaturas", + "drop_import": "Cópia para a biblioteca" } }, "smartRuleSummary": { @@ -3390,8 +3391,27 @@ "openToPlay": { "files": "Reproduzir ficheiros…", "folder": "Reproduzir uma pasta…", - "filesTitle": "Escolha os ficheiros a reproduzir", - "folderTitle": "Escolha a pasta a reproduzir", + "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 074a0dc73..b4991f8b4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1702,7 +1702,7 @@ }, "import": { "title": "Добавить в библиотеку", - "hint": "Папки или аудиофайлы: сканируются, затем отслеживаются" + "hint": "Скопировано в папку WaveFlow вашей музыки" } }, "abLoop": { @@ -3333,7 +3333,8 @@ "reconcile": "Сопоставление", "upload": "Отправка", "backup": "Резервная копия", - "thumbnails": "Миниатюры" + "thumbnails": "Миниатюры", + "drop_import": "Копирование в библиотеку" }, "more_few": "Ещё {{count}} задачи", "more_many": "Ещё {{count}} задач" @@ -3498,5 +3499,24 @@ "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 aa87d38b6..e6eea9382 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1588,7 +1588,7 @@ }, "import": { "title": "Kitaplığa ekle", - "hint": "Klasörler veya ses dosyaları; taranır, sonra izlenir" + "hint": "Müziğinizin WaveFlow klasörüne kopyalandı" } }, "abLoop": { @@ -3147,7 +3147,8 @@ "reconcile": "Eşleştirme", "upload": "Yükleme", "backup": "Yedekleme", - "thumbnails": "Kapak önizlemeleri" + "thumbnails": "Kapak önizlemeleri", + "drop_import": "Kitaplığa kopyalama" } }, "smartRuleSummary": { @@ -3290,5 +3291,24 @@ "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 0fb1e5fb8..5b32b908f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1586,7 +1586,7 @@ }, "import": { "title": "添加到音乐库", - "hint": "文件夹或音频文件,扫描后持续监视" + "hint": "复制到音乐中的 WaveFlow 文件夹" } }, "abLoop": { @@ -3145,7 +3145,8 @@ "reconcile": "比对", "upload": "上传", "backup": "备份", - "thumbnails": "缩略图" + "thumbnails": "缩略图", + "drop_import": "复制到音乐库" } }, "smartRuleSummary": { @@ -3288,5 +3289,24 @@ "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 8bf6d953c..16de7cb58 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1586,7 +1586,7 @@ }, "import": { "title": "加入音樂庫", - "hint": "資料夾或音訊檔,掃描後持續監看" + "hint": "複製到音樂中的 WaveFlow 資料夾" } }, "abLoop": { @@ -3145,7 +3145,8 @@ "reconcile": "比對", "upload": "上傳", "backup": "備份", - "thumbnails": "縮圖" + "thumbnails": "縮圖", + "drop_import": "複製到音樂庫" } }, "smartRuleSummary": { @@ -3288,5 +3289,24 @@ "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/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 }); } /** From a16107a6ac927e1b737b0c27c1f957294e23f877 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 08:14:04 +0200 Subject: [PATCH 4/6] fix(library): copy every cover format the scanner reads with a dropped folder The drop's sidecar list knew jpg, png and webp only, so a folder.bmp or cover.gif stayed behind. It now reuses the scanner's FOLDER_COVER_EXTENSIONS. --- docs/features/library.md | 2 +- src-tauri/crates/app/src/drop_import.rs | 11 +++++++---- src-tauri/crates/core/src/scanner/extract.rs | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/features/library.md b/docs/features/library.md index dd693a312..87ea0a942 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -249,7 +249,7 @@ UI: per-folder trash button in the Library → Folders tab, two-step confirm-on- **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, `.lrc`, `.ttml`); a dropped file goes to the root, with its same-name `.lrc`. Nothing is renamed from its tags. The `.waveflow` branch is skipped, as the scanner skips it. +- **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 `.lrc`. 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. 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. diff --git a/src-tauri/crates/app/src/drop_import.rs b/src-tauri/crates/app/src/drop_import.rs index 155afdff7..f1574e31d 100644 --- a/src-tauri/crates/app/src/drop_import.rs +++ b/src-tauri/crates/app/src/drop_import.rs @@ -27,6 +27,7 @@ use std::path::{Path, PathBuf}; use serde::Serialize; +use waveflow_core::scanner::extract::FOLDER_COVER_EXTENSIONS; use waveflow_core::scanner::folders::{relation, FolderRelation}; use crate::commands::scan::ScanSummary; @@ -34,9 +35,10 @@ use crate::commands::scan::ScanSummary; /// The managed folder's name under the user's music folder. pub const MANAGED_FOLDER_NAME: &str = "WaveFlow"; -/// Files beside the audio that a dropped folder brings along: the app reads -/// folder covers and lyrics sidecars from them. -const SIDECAR_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "lrc", "ttml"]; +/// Lyrics sidecars a dropped folder brings along beside its audio. Its +/// 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; @@ -235,8 +237,9 @@ fn is_sidecar(path: &Path) -> bool { path.extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| { - SIDECAR_EXTENSIONS + FOLDER_COVER_EXTENSIONS .iter() + .chain(LYRICS_EXTENSIONS) .any(|s| ext.eq_ignore_ascii_case(s)) }) } 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 From 123ce19ce4a5e1e2123ad79d13538d373f434bcf Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 08:35:29 +0200 Subject: [PATCH 5/6] fix(library): stoppable drop walk, lyrics that follow their audio and reported play failures The drop's task is announced before the walk and its flag stops it; unreadable entries count as errors; a lyrics sidecar is copied under the name its audio lands at; overlap and indexing_folder move to core's scanner::folders. Playing without importing reports a failed picker or command through the playback alert. --- docs/features/library.md | 6 +- docs/features/playback.md | 2 +- src-tauri/crates/app/src/commands/library.rs | 52 ++- src-tauri/crates/app/src/drop_import.rs | 411 ++++++++++++------- src-tauri/crates/core/src/scanner/folders.rs | 119 +++++- src/components/common/PlaybackAlertToast.tsx | 3 + src/i18n/locales/ar.json | 4 +- src/i18n/locales/de.json | 4 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/hi.json | 4 +- src/i18n/locales/id.json | 4 +- src/i18n/locales/it.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/ko.json | 4 +- src/i18n/locales/nl.json | 4 +- src/i18n/locales/pt-BR.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/tr.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/locales/zh-TW.json | 4 +- src/lib/openToPlay.ts | 36 +- 24 files changed, 494 insertions(+), 203 deletions(-) diff --git a/docs/features/library.md b/docs/features/library.md index 87ea0a942..22aa41793 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -241,7 +241,7 @@ 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::relation`](../../src-tauri/crates/core/src/scanner/folders.rs)): `.` 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. +**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 @@ -249,11 +249,11 @@ UI: per-folder trash button in the Library → Folders tab, two-step confirm-on- **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 `.lrc`. Nothing is renamed from its tags. The `.waveflow` branch is skipped, as the scanner skips it. +- **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. 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 and 200 000 entries walked per drop; past that the drop is cut short and says so. The copy is a `drop_import` task in the status bar, stopped by its flag between two files; what was copied is still scanned. +- **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. diff --git a/docs/features/playback.md b/docs/features/playback.md index 9a82d6260..58b01cbd7 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -469,7 +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. +- **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/src-tauri/crates/app/src/commands/library.rs b/src-tauri/crates/app/src/commands/library.rs index 7002dbd06..7672540ad 100644 --- a/src-tauri/crates/app/src/commands/library.rs +++ b/src-tauri/crates/app/src/commands/library.rs @@ -432,7 +432,7 @@ 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)) = crate::drop_import::overlap( + if let Some((relation, existing)) = waveflow_core::scanner::folders::overlap( std::path::Path::new(&path), folders.iter().map(|(_, folder)| folder.as_path()), ) { @@ -481,7 +481,7 @@ pub async fn import_dropped( library_id: i64, paths: Vec, ) -> AppResult { - use crate::drop_import::{self, Copied, DropImportSummary}; + use crate::drop_import::{self, DropImportSummary}; let managed = drop_import::managed_folder() .ok_or_else(|| AppError::Other("no music folder to copy into".into()))?; @@ -507,39 +507,44 @@ pub async fn import_dropped( // 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 = - drop_import::indexing_folder(&managed, &folders).map_err(|(relation, existing)| { - AppError::FolderOverlap { - relation, - existing: existing.to_string_lossy().into_owned(), - } - })?; + 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 || { - let plan = drop_import::plan(&dropped, ©_managed, &library); + // 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() }; - if plan.copies.is_empty() { + if out.cancelled || plan.copies.is_empty() { return out; } - // The registry's own flag stops the copy, as it does a scan. let total = plan.copies.len() as u64; - let task = crate::tasks::start( - ©_app, - crate::tasks::TaskKind::DropImport, - total, - crate::tasks::Cancellation::Flag, - ); for (done, copy) in plan.copies.iter().enumerate() { - if task.as_ref().is_some_and(|t| t.is_cancelling()) { + if should_stop() { out.cancelled = true; break; } @@ -549,14 +554,7 @@ pub async fn import_dropped( task.detail(name.to_string_lossy()); } } - match drop_import::copy_one(©.from, ©.to) { - Ok(Copied::New(_)) => out.copied += 1, - Ok(Copied::AlreadyThere) => out.already_copied += 1, - Err(err) => { - tracing::warn!(from = %copy.from.display(), %err, "drop import: copy failed"); - out.errors += 1; - } - } + drop_import::apply(copy, &mut out); } out }) diff --git a/src-tauri/crates/app/src/drop_import.rs b/src-tauri/crates/app/src/drop_import.rs index f1574e31d..7d00f65a0 100644 --- a/src-tauri/crates/app/src/drop_import.rs +++ b/src-tauri/crates/app/src/drop_import.rs @@ -12,7 +12,10 @@ //! 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 `.lrc`. +//! 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 @@ -24,20 +27,21 @@ //! - 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 serde::Serialize; use waveflow_core::scanner::extract::FOLDER_COVER_EXTENSIONS; -use waveflow_core::scanner::folders::{relation, FolderRelation}; +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 a dropped folder brings along beside its audio. Its -/// cover images go too, by the scanner's own list -/// ([`FOLDER_COVER_EXTENSIONS`]), so the two cannot drift. +/// 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. @@ -47,6 +51,9 @@ const MAX_FILES: usize = 20_000; /// 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 { @@ -60,7 +67,7 @@ pub struct DropImportSummary { pub in_library: u32, /// Dropped folders holding a library folder, refused. pub refused: u32, - /// Files that could not be read or written. + /// 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, @@ -78,73 +85,13 @@ pub fn managed_folder() -> Option { .map(|music| music.join(MANAGED_FOLDER_NAME)) } -/// The library folder that will index the managed folder's files: the -/// managed folder's own row, else the deepest library folder holding it (a -/// user whose library is the whole music folder already scans it). `None` -/// when neither exists and the managed folder must be added. -/// -/// A library folder *inside* the managed folder is an overlap the drop -/// 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 = path.components().count(); - // 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 library folder `candidate` overlaps, if any, and how: the same folder -/// first, then one holding it, then one under it. -pub fn overlap<'a>( - candidate: &Path, - folders: impl IntoIterator, -) -> Option<(FolderRelation, PathBuf)> { - let mut found: Option<(FolderRelation, PathBuf)> = None; - for folder in folders { - let Some(rel) = relation(candidate, folder) else { - continue; - }; - let rank = |r: FolderRelation| match r { - FolderRelation::Same => 0, - FolderRelation::Inside => 1, - FolderRelation::Contains => 2, - }; - let closer = match &found { - Some((kept, _)) => rank(rel) < rank(*kept), - None => true, - }; - if closer { - found = Some((rel, folder.to_path_buf())); - } - } - found -} - -/// One file to copy, and where. +/// 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. @@ -153,16 +100,32 @@ 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. -pub fn plan(dropped: &[PathBuf], managed: &Path, library: &[PathBuf]) -> Plan { +/// (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; @@ -175,33 +138,8 @@ pub fn plan(dropped: &[PathBuf], managed: &Path, library: &[PathBuf]) -> Plan { None => {} } if item.is_dir() { - let base = managed.join(item.file_name().unwrap_or_else(|| "Import".as_ref())); - let walk = walkdir::WalkDir::new(item) - .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()) - }); - for entry in walk.flatten() { - visited += 1; - if visited >= MAX_VISITED || out.copies.len() >= MAX_FILES { - out.truncated = true; - return out; - } - if !entry.file_type().is_file() - || !(is_audio(entry.path()) || is_sidecar(entry.path())) - { - continue; - } - let Ok(rel) = entry.path().strip_prefix(item) else { - continue; - }; - out.copies.push(Copy { - to: base.join(rel), - from: entry.into_path(), - }); + 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 { @@ -214,34 +152,151 @@ pub fn plan(dropped: &[PathBuf], managed: &Path, library: &[PathBuf]) -> Plan { 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(), }); - let lyrics = item.with_extension("lrc"); - if lyrics.is_file() { - if let Some(name) = lyrics.file_name() { - out.copies.push(Copy { - to: managed.join(name), - from: lyrics, - }); - } - } } } 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 is_sidecar(path: &Path) -> bool { +fn has_extension(path: &Path, extensions: &[&str]) -> bool { path.extension() .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - FOLDER_COVER_EXTENSIONS - .iter() - .chain(LYRICS_EXTENSIONS) - .any(|s| ext.eq_ignore_ascii_case(s)) - }) + .is_some_and(|ext| extensions.iter().any(|e| ext.eq_ignore_ascii_case(e))) +} + +/// Make one planned copy, its lyrics after it, and count the outcome. +pub fn apply(copy: &Copy, summary: &mut DropImportSummary) { + 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. @@ -249,8 +304,8 @@ fn is_sidecar(path: &Path) -> bool { 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. - AlreadyThere, + /// 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 @@ -261,7 +316,9 @@ pub fn copy_one(from: &Path, to: &Path) -> std::io::Result { let mut n = 1u32; loop { match std::fs::metadata(&target) { - Ok(meta) if meta.is_file() && meta.len() == size => return Ok(Copied::AlreadyThere), + Ok(meta) if meta.is_file() && meta.len() == size => { + return Ok(Copied::AlreadyThere(target)) + } Ok(_) => { n += 1; if n > 99 { @@ -273,17 +330,34 @@ pub fn copy_one(from: &Path, to: &Path) -> std::io::Result { 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)) { + 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(Copied::New(target)) + Ok(()) } /// `name (n).ext` beside `path`. @@ -305,31 +379,43 @@ mod tests { std::fs::write(path, bytes).unwrap(); } + fn never() -> bool { + false + } + #[test] - fn a_folder_keeps_its_tree_and_brings_covers_and_lyrics() { + 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("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, &[]); + 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(); - let base = managed.join("Album"); assert_eq!( to, vec![ base.join("01.flac"), - base.join("01.lrc"), 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] @@ -341,9 +427,15 @@ mod tests { 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, &[]); - let to: Vec<_> = plan.copies.iter().map(|c| c.to.clone()).collect(); - assert_eq!(to, vec![managed.join("song.mp3"), managed.join("song.lrc")]); + 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] @@ -363,32 +455,20 @@ mod tests { music.clone(), managed.join("03.flac"), ]; - let plan = plan(&dropped, &managed, std::slice::from_ref(&library)); + 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 the_managed_folder_is_indexed_by_its_row_or_the_folder_holding_it() { - let managed = PathBuf::from("/m/Music/WaveFlow"); - let music = (1, PathBuf::from("/m/Music")); - let own = (2, managed.clone()); - let other = (3, PathBuf::from("/m/Downloads")); - assert_eq!( - indexing_folder(&managed, std::slice::from_ref(&other)), - Ok(None) - ); - assert_eq!( - indexing_folder(&managed, &[music.clone(), other.clone()]), - Ok(Some(1)) - ); - assert_eq!(indexing_folder(&managed, &[music, own]), Ok(Some(2))); - let under = (4, managed.join("Album")); - assert_eq!( - indexing_folder(&managed, std::slice::from_ref(&under)), - Err((FolderRelation::Contains, under.1)) - ); + 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] @@ -399,7 +479,10 @@ mod tests { 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); + assert_eq!( + copy_one(&from, &to).unwrap(), + Copied::AlreadyThere(to.clone()) + ); let other = dir.path().join("in2").join("song.mp3"); touch(&other, b"abcd"); @@ -417,4 +500,36 @@ mod tests { .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/core/src/scanner/folders.rs b/src-tauri/crates/core/src/scanner/folders.rs index 7d8ae1d9c..13a63142b 100644 --- a/src-tauri/crates/core/src/scanner/folders.rs +++ b/src-tauri/crates/core/src/scanner/folders.rs @@ -13,7 +13,7 @@ //! are not followed, which `canonicalize` would do, adding a `\\?\` prefix //! on Windows that no stored path has. -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; /// Where a candidate folder lies relative to a library folder. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] @@ -46,6 +46,70 @@ pub fn relation(candidate: &Path, folder: &Path) -> Option { } } +/// 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 { @@ -72,7 +136,6 @@ fn parts(path: &Path) -> Vec { #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; fn p(s: &str) -> PathBuf { PathBuf::from(s) @@ -112,6 +175,58 @@ mod tests { ); } + #[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() { 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/i18n/locales/ar.json b/src/i18n/locales/ar.json index 581ec35bb..46cf45941 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1135,7 +1135,9 @@ "decoder-crashed": "أُعيد تشغيل محرك الصوت", "decoder-stopped": "توقف محرك الصوت بعد أخطاء متكررة", "offline": "وضع عدم الاتصال مفعّل", - "unknown": "فشل التشغيل" + "unknown": "فشل التشغيل", + "files-failed": "تعذّر تشغيل هذه الملفات", + "picker-failed": "تعذّر فتح منتقي الملفات" }, "notice": { "exclusive-refused": "هذا الجهاز يرفض الوضع الحصري: التشغيل في الوضع المشترك", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 15e4a6a86..57525658d 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -997,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", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0c6da3fd3..9aaf1b3a5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -997,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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 53123c383..3804c592e 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1037,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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2897b7117..50bd91a15 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1037,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é", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 07f3cfd0e..2027287a8 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -997,7 +997,9 @@ "decoder-crashed": "ऑडियो इंजन फिर से शुरू हुआ", "decoder-stopped": "बार-बार त्रुटियों के बाद ऑडियो इंजन रुक गया", "offline": "ऑफ़लाइन मोड चालू है", - "unknown": "प्लेबैक विफल रहा" + "unknown": "प्लेबैक विफल रहा", + "files-failed": "इन फ़ाइलों को नहीं चलाया जा सका", + "picker-failed": "फ़ाइल चयनकर्ता नहीं खोला जा सका" }, "notice": { "exclusive-refused": "यह डिवाइस एक्सक्लूसिव मोड स्वीकार नहीं करता: साझा मोड में चल रहा है", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index c7ca7ddbc..3f0023645 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -995,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", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index ba22957a7..d843c909e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1037,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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 78bba895c..7e814c3bf 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -995,7 +995,9 @@ "decoder-crashed": "オーディオエンジンを再起動しました", "decoder-stopped": "エラーが続いたためオーディオエンジンを停止しました", "offline": "オフラインモードが有効です", - "unknown": "再生に失敗しました" + "unknown": "再生に失敗しました", + "files-failed": "これらのファイルを再生できません", + "picker-failed": "ファイル選択ダイアログを開けません" }, "notice": { "exclusive-refused": "このデバイスは排他モードを受け付けません。共有モードで再生します", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 96bdd1cb1..61df60803 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -995,7 +995,9 @@ "decoder-crashed": "오디오 엔진이 다시 시작되었습니다", "decoder-stopped": "오류가 반복되어 오디오 엔진이 중지되었습니다", "offline": "오프라인 모드가 켜져 있습니다", - "unknown": "재생에 실패했습니다" + "unknown": "재생에 실패했습니다", + "files-failed": "이 파일들을 재생할 수 없습니다", + "picker-failed": "파일 선택기를 열 수 없습니다" }, "notice": { "exclusive-refused": "이 장치는 독점 모드를 허용하지 않습니다. 공유 모드로 재생합니다", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 100a83867..78923a0ba 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -997,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", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index fd961bbfa..818ed39cb 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1038,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", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 8a745d29d..70705b6f5 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1038,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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b4991f8b4..62b7878ab 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1081,7 +1081,9 @@ "decoder-crashed": "Аудиодвижок перезапущен", "decoder-stopped": "Аудиодвижок остановлен после нескольких ошибок", "offline": "Включён офлайн-режим", - "unknown": "Воспроизведение не удалось" + "unknown": "Воспроизведение не удалось", + "files-failed": "Не удаётся воспроизвести эти файлы", + "picker-failed": "Не удаётся открыть окно выбора файлов" }, "notice": { "exclusive-refused": "Это устройство не принимает монопольный режим: воспроизведение в общем режиме", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index e6eea9382..c37412bb5 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -997,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", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5b32b908f..5a0f74ac5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -995,7 +995,9 @@ "decoder-crashed": "音频引擎已重启", "decoder-stopped": "多次出错后音频引擎已停止", "offline": "离线模式已开启", - "unknown": "播放失败" + "unknown": "播放失败", + "files-failed": "无法播放这些文件", + "picker-failed": "无法打开文件选择器" }, "notice": { "exclusive-refused": "此设备不接受独占模式:以共享模式播放", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 16de7cb58..f0150af63 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -995,7 +995,9 @@ "decoder-crashed": "音訊引擎已重新啟動", "decoder-stopped": "多次錯誤後音訊引擎已停止", "offline": "離線模式已開啟", - "unknown": "播放失敗" + "unknown": "播放失敗", + "files-failed": "無法播放這些檔案", + "picker-failed": "無法開啟檔案選擇器" }, "notice": { "exclusive-refused": "此裝置不接受獨佔模式:以共用模式播放", diff --git a/src/lib/openToPlay.ts b/src/lib/openToPlay.ts index 35107d9ed..2f3e8d56b 100644 --- a/src/lib/openToPlay.ts +++ b/src/lib/openToPlay.ts @@ -1,3 +1,4 @@ +import { emit } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import i18n from "../i18n"; import { playerPlayPaths } from "./tauri/player"; @@ -23,17 +24,42 @@ export const PLAYABLE_EXTENSIONS = [ "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) { - console.error("[openToPlay] could not play", 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 []; } } @@ -44,7 +70,7 @@ function asList(result: string | string[] | null): string[] { /** Pick audio files and play them without importing them. */ export async function openFilesToPlay(): Promise { - const picked = await open({ + const picked = await pick({ multiple: true, directory: false, title: i18n.t("openToPlay.filesTitle"), @@ -55,15 +81,15 @@ export async function openFilesToPlay(): Promise { }, ], }); - await playWithoutImporting(asList(picked)); + await playWithoutImporting(picked); } /** Pick a folder and play the audio files in it without importing them. */ export async function openFolderToPlay(): Promise { - const picked = await open({ + const picked = await pick({ multiple: false, directory: true, title: i18n.t("openToPlay.folderTitle"), }); - await playWithoutImporting(asList(picked)); + await playWithoutImporting(picked); } From 35868a8aacbbe36d3ba5ff0833d54fcf6bb64d70 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 08:55:20 +0200 Subject: [PATCH 6/6] fix(library): serialize drop copies so two drops cannot claim the same target Choosing a free target name and the rename that completes the copy now run under one process-wide lock, recovered if poisoned, so concurrent drops take turns file by file instead of writing the same .waveflow-part. --- docs/features/library.md | 2 +- src-tauri/crates/app/src/drop_import.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/features/library.md b/docs/features/library.md index 22aa41793..b054c1266 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -251,7 +251,7 @@ UI: per-folder trash button in the Library → Folders tab, two-step confirm-on- - **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. 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. +- **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. diff --git a/src-tauri/crates/app/src/drop_import.rs b/src-tauri/crates/app/src/drop_import.rs index 7d00f65a0..13a3a7c6b 100644 --- a/src-tauri/crates/app/src/drop_import.rs +++ b/src-tauri/crates/app/src/drop_import.rs @@ -29,6 +29,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use serde::Serialize; use waveflow_core::scanner::extract::FOLDER_COVER_EXTENSIONS; @@ -267,8 +268,21 @@ fn has_extension(path: &Path, extensions: &[&str]) -> bool { .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;