From 0f4481fb270a55d15b3ad6f14f2977bfd94af7fe Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 21 Sep 2026 19:50:48 +0200 Subject: [PATCH 1/2] fix: the spectrum no longer hits a ceiling, and the properties dialog suggests tags Spectrum - The curve went out through the top of the canvas and was cut flat: Catmull-Rom overshoots between two tall bands. Its Bezier control points are now held inside the drawing band, and a segment never leaves the hull of its control points. - A hit louder than the level reference was clipped to 1, so every band of it formed one plateau. Above 0.8 the scale now bends (hyperbolic soft limit) and never reaches 1, so the loudest band still stands out. Track properties - Edit moves to the header, next to Close, instead of the footer a scroll away. - The read-only view shows the genre and the disc number. Track and disc are two rows: "1 / 37" read as track 1 of 37 when it meant disc 1, track 37. - Artist, album and genre suggest what the library already holds, filtered as you type, and the genre also offers the common ID3v1 / Winamp genres. A pick replaces the value: the save stores one genre, so completing a list would make a genre named "Rock; Pop". --- docs/features/library.md | 2 +- docs/features/playback.md | 6 +- src-tauri/crates/app/src/audio/spectrum.rs | 53 +++- src/components/common/TagCombobox.tsx | 273 ++++++++++++++++++ .../common/TrackPropertiesModal.tsx | 157 +++++++--- src/components/player/SpectrumVisualizer.tsx | 17 +- src/i18n/locales/ar.json | 7 +- src/i18n/locales/de.json | 7 +- src/i18n/locales/en.json | 7 +- src/i18n/locales/es.json | 7 +- src/i18n/locales/fr.json | 7 +- src/i18n/locales/hi.json | 7 +- src/i18n/locales/id.json | 7 +- src/i18n/locales/it.json | 7 +- src/i18n/locales/ja.json | 7 +- src/i18n/locales/ko.json | 7 +- src/i18n/locales/nl.json | 7 +- src/i18n/locales/pt-BR.json | 7 +- src/i18n/locales/pt.json | 7 +- src/i18n/locales/ru.json | 7 +- src/i18n/locales/tr.json | 7 +- src/i18n/locales/zh-CN.json | 7 +- src/i18n/locales/zh-TW.json | 7 +- src/lib/genrePresets.ts | 206 +++++++++++++ 24 files changed, 753 insertions(+), 80 deletions(-) create mode 100644 src/components/common/TagCombobox.tsx create mode 100644 src/lib/genrePresets.ts diff --git a/docs/features/library.md b/docs/features/library.md index 67faadb60..656b78042 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -86,7 +86,7 @@ Tag edits go through the same funnel: [`edit.rs`](../../src-tauri/crates/app/src - **Bulk list endpoints wire format** — `list_tracks` / `list_playlist_tracks` / `list_liked_tracks` (track-shaped) and `list_albums` / `list_artists` (browse-shaped) all return `{ artwork_base, items: [] }` instead of the full row shape (artists additionally include `metadata_artwork_base` for the Deezer cache). Each slim row carries `artwork_hash` + `artwork_format` + `artwork_has_1x` + `artwork_has_2x` (artists also `picture_hash` + `picture_has_*`) instead of three full path strings; the ~70-char per-profile prefix only appears once in the response. Frontend wrappers ([`expandTrackResponse`](../../src/lib/tauri/track.ts), `expandAlbumRow` / `expandArtistRow` in [`browse.ts`](../../src/lib/tauri/browse.ts)) stitch the absolute paths back together so every UI consumer keeps the full `Track` / `AlbumRow` / `ArtistRow` shape unchanged. Cuts ~30 % off each payload (e.g. ≈ 1.0 MB → ≈ 700 kB on a 1k-track `list_tracks`, ≈ 650 kB → ≈ 250 kB on a 900-artist `list_artists`), proportionally shrinking JSON parse + IPC transfer time. Any future bulk endpoint shipping artworks for hundreds of rows should adopt the same `{ artwork_base, items }` shape. - **A-Z navigator** — letter rail on the artists tab, NFD-normalised so accents (É → E, Ñ → N) bucket correctly. - **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. -- **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. +- **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. The read-only view shows every tag the form edits (track and disc number on two rows — the old "1 / 37" read as track 1 of 37 — and the genre), and **Edit** sits in the header next to Close rather than in the footer a scroll away. In the form, artist, album and genre are [`TagCombobox`](../../src/components/common/TagCombobox.tsx) fields: click or type and a list opens with what the library already holds, filtered as you type (accent- and case-insensitive), and for the genre the common genres of [`genrePresets.ts`](../../src/lib/genrePresets.ts) (the ID3v1 / Winamp list tag editors propose, trimmed, plus a few recent genres) — anything typed is still taken as is. A pick replaces the genre rather than adding one: the save stores one genre, so completing a `;`-separated list would create a genre named after the whole list. The list is portalled (the metadata section clips with `overflow: hidden`) and focus never leaves the input, so Escape closes the list first and the dialog second. - **POPM ratings** — 5-star with half-steps, round-tripped to the file's tag. Edit surfaces: inline `StarRating` in the library track list, integer-star submenu in the right-click `TrackContextMenu` (any view), full half-star widget in the `TrackPropertiesModal`. The backend command `set_track_rating` writes the POPM frame back to the file (binary `\0` for ID3v2, text `RATING=0-100` for Vorbis / MP4 / APE), updates `track.rating` in the DB, then emits `track:updated` so every open view re-fetches without polling. The write goes through `edit::patch_file` like every other tag edit (#644), so a `.dsf` gets its POPM frame through the DSF writer rather than stopping at the database. **ID3v2 ratings are written as the POPM frame itself**, on the concrete tag, and that is not a stylistic choice: lofty 0.25 changed what a POPM looks like on its generic tag — it arrives as `||` through a whole-star `StarRating` rather than as the raw frame body its own documentation still describes — so a 0-255 value in half-star steps put into the generic tag is dropped on the way back to the file, silently, and a plain number or a raw body produces no frame at all (measured). The scanner reads both shapes for the same reason: reading only the old one is why an MP3's rating came back as nothing after that bump. `.dff` is still refused, with the reason. WaveFlow does not read a rating back out of a DSD file — the DSD extractor lifts title / artist / album / year and no more — so the database stays the copy this app trusts; what the write buys is the rating travelling with the file. Smart playlists expose this as the `rating_min` rule — see [smart-playlists.md](smart-playlists.md#custom-smart-playlists-recursive-boolean-rule-tree). - **Lightbox** — double-click any cover or artist photo to view full-size with keyboard navigation. diff --git a/docs/features/playback.md b/docs/features/playback.md index cf701b932..9352105fd 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -16,11 +16,11 @@ Real-time FFT curves surfaced in the immersive Now Playing overlay. Implementati - Backend: [`audio/spectrum.rs`](../../src-tauri/crates/app/src/audio/spectrum.rs) runs on the decoder thread (NOT in the cpal callback — too constrained). Post-EQ samples go through `SpectrumAnalyzer::feed`, which mono-mixes, applies a Hann window, runs a 4096-pt real FFT via `realfft`, then buckets the magnitudes into 48 log-spaced bands (30 Hz → 16 kHz). The window slides by a fixed 1024 samples (75% overlap), so the longer window does not slow the update. Throttled to ~30 Hz via a manual `Instant` clock. - **Bass resolution (#715).** At 2048 points a bin is 21.5 Hz wide at 44.1 kHz, and the twelve bands between 30 and 144 Hz were fed by six distinct bins: they moved in identical pairs. At 4096 a bin is 10.8 Hz, and a band still narrower than a bin reads the spectrum at its centre frequency, interpolated between the two nearest bins, so no two bands report the same number. -- **Level (#715).** Bands are scaled against a running reference that follows the loudest band — up in ~250 ms, down over ~3 s, with a floor so silence is never amplified into noise — plus 15% headroom. It replaced a fixed reference of 250, which a quietly mastered track never approached. The goal is the right movement, not more of it: no smoothing is added here, the React side keeps its own attack / release, and the reference resets on track change. -- Output is a `player:spectrum` Tauri event carrying a `Vec` of normalised band magnitudes (0..1, peaks may briefly overshoot). +- **Level (#715).** Bands are scaled against a running reference that follows the loudest band — up in ~250 ms, down over ~3 s, with a floor so silence is never amplified into noise — plus 15% headroom. It replaced a fixed reference of 250, which a quietly mastered track never approached. **Above 0.8 the scale bends instead of clipping** (`soft_limit`, hyperbolic): the reference takes a fraction of a second to catch a sudden hit, and clipping it to 1 put every band of the hit on the same flat plateau at the top — compressed, they approach the top without reaching it and the loudest still stands above its neighbours. Hyperbolic rather than exponential because an exponential is within `f32` rounding of 1 a few times past the reference, which is the very transient it is for. The goal is the right movement, not more of it: no smoothing is added here, the React side keeps its own attack / release, and the reference resets on track change. +- Output is a `player:spectrum` Tauri event carrying a `Vec` of normalised band magnitudes (0..1, never reaching 1 — see the soft limit above). - A `SharedPlayback::visualizer_enabled` atomic gates the entire path: when off, `feed` returns at the first atomic load — zero allocations, zero FFT cost. Persisted in `profile_setting['ui.visualizer']`, default OFF. - Frontend: [`SpectrumVisualizer`](../../src/components/player/SpectrumVisualizer.tsx) subscribes to the event and drives a `` with `requestAnimationFrame`. Asymmetric decay (jump up fast, fall slow) so transients pop without looking glitchy. Auto-fades to zero on pause so the drawing doesn't freeze mid-pose — a curve settles onto a flat line. -- Drawing style (issue #699): per profile via [`useVisualizerStyle`](../../src/hooks/useVisualizerStyle.ts) — `Wave` (**default**) is one Catmull-Rom curve through the 48 band tops, emitted as cubic Béziers and filled underneath with a gradient that fades out; `Mirrored` reflects it around a centre line as a single closed shape, so it reads as a waveform rather than a histogram; `Bars` keeps the original rectangles, with rounded caps. Stored in `profile_setting['ui.visualizer_style']` and cycled from a [`VisualizerStyleButton`](../../src/components/player/VisualizerStyleButton.tsx) beside the colour one, under the same "only when the visualizer is on, only once the stored value has loaded" gate. **The default changes the look for existing installs**, unlike the colour choice below: the complaint in #699 is the bars themselves, so defaulting to them would ship the fix switched off — and they are one click away on the same button. The analysis is untouched; only the drawing changed. +- Drawing style (issue #699): per profile via [`useVisualizerStyle`](../../src/hooks/useVisualizerStyle.ts) — `Wave` (**default**) is one Catmull-Rom curve through the 48 band tops, emitted as cubic Béziers and filled underneath with a gradient that fades out — its control points are held inside the drawing band, because Catmull-Rom overshoots between two tall bands and the canvas edge used to cut the curve flat there (a Bézier segment never leaves the hull of its control points); `Mirrored` reflects it around a centre line as a single closed shape, so it reads as a waveform rather than a histogram; `Bars` keeps the original rectangles, with rounded caps. Stored in `profile_setting['ui.visualizer_style']` and cycled from a [`VisualizerStyleButton`](../../src/components/player/VisualizerStyleButton.tsx) beside the colour one, under the same "only when the visualizer is on, only once the stored value has loaded" gate. **The default changes the look for existing installs**, unlike the colour choice below: the complaint in #699 is the bars themselves, so defaulting to them would ship the fix switched off — and they are one click away on the same button. The analysis is untouched; only the drawing changed. - Render cost: it runs at display refresh rate for as long as the immersive view is open, so **the frame loop allocates nothing**. A canvas gradient is bound to the coordinates it was built with, so it is rebuilt on resize / colour change and cached otherwise; the curve is emitted by index straight into one path rather than through an array of points; and the mirror's lower edge flips a sign the helpers read instead of taking a fresh closure sixty times a second. `prefers-reduced-motion` damps both halves of the smoothing rather than animating harder, and in [high contrast](ui.md#high-contrast) the stroke doubles and the fade-to-transparent fill becomes a flat wash — a fill that fades out is exactly the low-contrast edge that mode exists to remove. The contrast state is read off the `data-contrast` attribute the rest of the window is painted from, not through the profile-setting hook: a decorative canvas has no business holding one open. - Bar colour (issue #468): user-selectable per profile via [`useVisualizerColor`](../../src/hooks/useVisualizerColor.ts) — `White` (default, the historical `rgba(255,255,255,0.85)` so existing installs are unchanged) → `Emerald` → `Orange` → `Aqua` → `Magenta` → `Rainbow` (per-bar 0–300° hue sweep), stored in `profile_setting['ui.visualizer_color']`. A [`VisualizerColorButton`](../../src/components/player/VisualizerColorButton.tsx) next to the like/★ in [`ImmersiveNowPlaying`](../../src/components/player/ImmersiveNowPlaying.tsx) cycles through them (loops back to `White`); it only appears when the visualizer toggle is on. Rationale: the immersive backdrop is derived from album art, so no single fixed colour reads well over every cover — the user picks one that contrasts. diff --git a/src-tauri/crates/app/src/audio/spectrum.rs b/src-tauri/crates/app/src/audio/spectrum.rs index 6f3f98bcf..d5597a1b1 100644 --- a/src-tauri/crates/app/src/audio/spectrum.rs +++ b/src-tauri/crates/app/src/audio/spectrum.rs @@ -305,6 +305,13 @@ fn band_magnitudes(spectrum: &[Complex], sample_rate: f32, bands: &mut [f32 /// against the track's own recent level (with [`HEADROOM`] above it) /// makes the peaks of the music reach the top whatever the master. /// +/// Above [`KNEE`] the scale bends instead of stopping: a transient +/// louder than the reference — the reference takes a fraction of a +/// second to catch up with it — used to be clipped to 1, so every band +/// of the hit landed on the same flat plateau at the top of the display. +/// Compressed, they approach the top without reaching it, and the loudest +/// of them still stands above its neighbours. +/// /// A small floor then treats the bottom as silence, so quantisation and /// decoder rounding show as zero rather than a constant haze, and a /// `sqrt` curve expands the low end, where the ear is most sensitive to @@ -313,12 +320,33 @@ fn scale_bands(raw: &[f32], level: f32, bands: &mut [f32]) { const FLOOR: f32 = 0.02; let reference = level.max(MIN_LEVEL) * HEADROOM; for (band, &mag) in bands.iter_mut().zip(raw) { - let normalised = (mag / reference).clamp(0.0, 1.0); + let normalised = soft_limit((mag / reference).max(0.0)); let cut = (normalised - FLOOR).max(0.0) / (1.0 - FLOOR); *band = cut.sqrt(); } } +/// Where the scale starts to bend. Below it a band is linear in its +/// magnitude, as before. +const KNEE: f32 = 0.8; + +/// Identity up to [`KNEE`], then a hyperbolic approach to 1: continuous +/// and with the same slope at the knee, so nothing jumps where the +/// compression begins, and never reaching 1 however loud the band is. +/// +/// Hyperbolic rather than exponential: an exponential is within `f32` +/// rounding of 1 by a few times the reference, which is exactly the +/// transient this is for, and two bands would land on the same value +/// again. +fn soft_limit(x: f32) -> f32 { + if x <= KNEE { + return x; + } + let room = 1.0 - KNEE; + let over = (x - KNEE) / room; + KNEE + room * over / (1.0 + over) +} + #[cfg(test)] mod tests { use super::*; @@ -389,11 +417,32 @@ mod tests { #[test] fn a_single_beat_does_not_reset_the_scale() { // Rising takes a fraction of a second: one loud frame moves the - // reference only part of the way, so the beat itself pegs. + // reference only part of the way, so the beat itself reaches the + // top of the scale. let level = follow_level(100.0, 1000.0, EMIT_INTERVAL); assert!(level < 300.0, "got {level}"); } + /// A hit several times louder than the reference used to clip every + /// band of it to 1 — a flat plateau at the top of the display. The + /// louder band now still stands above the other, and neither reaches + /// the top. + #[test] + fn bands_louder_than_the_reference_stay_apart() { + let mut bands = [0.0; 2]; + scale_bands(&[200.0, 400.0], MIN_LEVEL, &mut bands); + assert!(bands[0] < bands[1], "got {bands:?}"); + assert!(bands[1] < 1.0, "got {bands:?}"); + assert!(bands[0] > 0.9, "got {bands:?}"); + } + + #[test] + fn the_soft_limit_is_continuous_at_the_knee() { + assert_eq!(soft_limit(KNEE), KNEE); + assert!((soft_limit(KNEE + 1e-3) - (KNEE + 1e-3)).abs() < 1e-5); + assert_eq!(soft_limit(0.3), 0.3); + } + #[test] fn analyzer_starts_idle_and_resets_clean() { let mut a = SpectrumAnalyzer::new(); diff --git a/src/components/common/TagCombobox.tsx b/src/components/common/TagCombobox.tsx new file mode 100644 index 000000000..41bc9e44c --- /dev/null +++ b/src/components/common/TagCombobox.tsx @@ -0,0 +1,273 @@ +import { useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChevronDown } from "lucide-react"; + +/** A titled group of suggestions, shown in order. */ +export interface SuggestionGroup { + label: string; + values: readonly string[]; +} + +interface TagComboboxProps { + value: string; + onChange: (value: string) => void; + groups: readonly SuggestionGroup[]; + placeholder?: string; + ariaLabel: string; + disabled?: boolean; + /** Classes of the text input; the list is sized to match it. */ + inputClassName: string; +} + +/** Suggestions shown per group. A library can hold thousands of artists, + * and the list is for picking one, not for browsing them all — typing + * narrows it. */ +const MAX_PER_GROUP = 80; + +/** Case- and accent-insensitive form, so "beyonce" finds "Beyoncé". */ +function fold(s: string): string { + return s + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLocaleLowerCase(); +} + +/** + * Text field with a list of suggestions, the way a tag editor offers the + * genres it knows: click or type and the list opens, filtered as you go, + * and anything typed is still accepted as is. + * + * The list is portalled and positioned against the input. The dialog it + * lives in scrolls, and its sections clip their rounded corners with + * `overflow: hidden`, which would cut an absolutely positioned list in + * half. Focus never leaves the input: the options are pointed at with + * `aria-activedescendant`, and a click on one is taken on mouse-down so + * the input keeps focus — which also keeps the dialog's focus trap and + * its backdrop out of it. + */ +export function TagCombobox({ + value, + onChange, + groups, + placeholder, + ariaLabel, + disabled = false, + inputClassName, +}: TagComboboxProps) { + const listId = useId(); + const inputRef = useRef(null); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(-1); + const [rect, setRect] = useState<{ + left: number; + width: number; + top?: number; + bottom?: number; + maxHeight: number; + } | null>(null); + + const query = fold(value.trim()); + + // Filtered groups, then flattened so the arrow keys walk through every + // group as one list. Starts-with matches come before the rest, and a + // value offered by an earlier group is not offered again by a later one + // (a genre in the library and in the presets shows once, as the + // library's). + const { shown, flat } = useMemo(() => { + const taken = new Set(); + // `start`: where the group's first value sits in `flat`, which is + // what an option's id and the highlighted index are counted in. + const shown: (SuggestionGroup & { start: number })[] = []; + const flat: string[] = []; + for (const g of groups) { + const starts: string[] = []; + const contains: string[] = []; + for (const v of g.values) { + const f = fold(v); + if (taken.has(f)) continue; + if (query === "" || f.startsWith(query)) starts.push(v); + else if (f.includes(query)) contains.push(v); + } + for (const v of g.values) taken.add(fold(v)); + const values = starts.concat(contains).slice(0, MAX_PER_GROUP); + if (values.length === 0) continue; + shown.push({ label: g.label, values, start: flat.length }); + flat.push(...values); + } + return { shown, flat }; + }, [groups, query]); + + const visible = open && flat.length > 0 && !disabled; + + // Placed against the input, below it or — when the dialog leaves no + // room there — above it. Re-measured on any scroll (captured, so the + // dialog's own scroll counts) and on resize. + useLayoutEffect(() => { + if (!visible) return; + const place = () => { + const el = inputRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const margin = 8; + const below = window.innerHeight - r.bottom - margin; + const above = r.top - margin; + const wanted = 256; + if (below >= Math.min(wanted, above)) { + setRect({ + left: r.left, + width: r.width, + top: r.bottom + 4, + maxHeight: Math.min(wanted, below - 4), + }); + } else { + setRect({ + left: r.left, + width: r.width, + bottom: window.innerHeight - r.top + 4, + maxHeight: Math.min(wanted, above - 4), + }); + } + }; + place(); + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [visible]); + + // Keep the highlighted option in view while the arrow keys move it. + useLayoutEffect(() => { + if (!visible || active < 0) return; + document + .getElementById(`${listId}-${active}`) + ?.scrollIntoView({ block: "nearest" }); + }, [visible, active, listId]); + + const pick = (choice: string) => { + onChange(choice); + setOpen(false); + setActive(-1); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + if (flat.length === 0) return; + e.preventDefault(); + if (!open) { + setOpen(true); + setActive(e.key === "ArrowDown" ? 0 : flat.length - 1); + return; + } + const step = e.key === "ArrowDown" ? 1 : -1; + setActive((i) => (i + step + flat.length) % flat.length); + } else if (e.key === "Enter") { + if (visible && active >= 0 && active < flat.length) { + e.preventDefault(); + pick(flat[active]); + } + } else if (e.key === "Escape") { + // First Escape closes the list only; the dialog listens on the + // document, so stopping it here keeps the dialog open. + if (visible) { + e.stopPropagation(); + setOpen(false); + setActive(-1); + } + } + }; + + const list = visible && rect && ( +
e.preventDefault()} + > + {shown.map((g) => ( +
+ {shown.length > 1 && ( +
+ {g.label} +
+ )} + {g.values.map((v, k) => { + const i = g.start + k; + return ( +
{ + e.preventDefault(); + pick(v); + }} + onMouseEnter={() => setActive(i)} + className={`cursor-pointer truncate px-3 py-1.5 ${ + i === active + ? "bg-emerald-500 text-white" + : "text-zinc-700 dark:text-zinc-200" + }`} + > + {v} +
+ ); + })} +
+ ))} +
+ ); + + return ( +
+ = 0 ? `${listId}-${active}` : undefined + } + value={value} + placeholder={placeholder} + disabled={disabled} + autoComplete="off" + spellCheck={false} + onChange={(e) => { + onChange(e.target.value); + setOpen(true); + setActive(-1); + }} + onClick={() => setOpen(true)} + onBlur={() => { + setOpen(false); + setActive(-1); + }} + onKeyDown={onKeyDown} + className={`${inputClassName} pr-7`} + /> +
+ ); +} diff --git a/src/components/common/TrackPropertiesModal.tsx b/src/components/common/TrackPropertiesModal.tsx index 1029e60a7..77a5a3f4d 100644 --- a/src/components/common/TrackPropertiesModal.tsx +++ b/src/components/common/TrackPropertiesModal.tsx @@ -24,6 +24,9 @@ import { type TrackEdit, } from "../../lib/tauri/track"; import { StarRating } from "./StarRating"; +import { TagCombobox, type SuggestionGroup } from "./TagCombobox"; +import { listAlbums, listArtists, listGenres } from "../../lib/tauri/browse"; +import { GENRE_PRESETS } from "../../lib/genrePresets"; import { pickFile } from "../../lib/tauri/dialog"; import { useTrackUpdated } from "../../hooks/useTrackUpdated"; import { useModalA11y } from "../../hooks/useModalA11y"; @@ -158,6 +161,54 @@ export function TrackPropertiesModal({ }; }, [track]); + // What the library already holds, offered while editing so a value is + // picked rather than retyped — a typo in an artist name makes a second + // artist. Loaded the first time the form opens, not with the dialog: + // most openings only look. A failed list only means fewer suggestions. + const [known, setKnown] = useState<{ + genres: string[]; + artists: string[]; + albums: string[]; + } | null>(null); + const wantsSuggestions = editing && known == null; + useEffect(() => { + if (!wantsSuggestions) return; + let cancelled = false; + const names = (p: Promise, pick: (row: T) => string) => + p + .then((rows) => + [...new Set(rows.map(pick).filter((n) => n.trim() !== ""))].sort( + (a, b) => a.localeCompare(b), + ), + ) + .catch((err) => { + console.error("[TrackProperties] suggestions failed", err); + return [] as string[]; + }); + void Promise.all([ + names(listGenres(null), (g) => g.name), + names(listArtists(null), (a) => a.name), + names(listAlbums(null), (a) => a.title), + ]).then(([genres, artists, albums]) => { + if (!cancelled) setKnown({ genres, artists, albums }); + }); + return () => { + cancelled = true; + }; + }, [wantsSuggestions]); + + const inLibrary = t("trackProperties.suggestions.library"); + const genreGroups: SuggestionGroup[] = [ + { label: inLibrary, values: known?.genres ?? [] }, + { label: t("trackProperties.suggestions.presets"), values: GENRE_PRESETS }, + ]; + const artistGroups: SuggestionGroup[] = [ + { label: inLibrary, values: known?.artists ?? [] }, + ]; + const albumGroups: SuggestionGroup[] = [ + { label: inLibrary, values: known?.albums ?? [] }, + ]; + // The genre isn't on the `Track` row (it lives in `track_genre`), so // it takes its own fetch. A failure leaves `genres` null, which keeps // the genre out of the payload entirely rather than sending an empty @@ -322,11 +373,15 @@ export function TrackPropertiesModal({ const addedAt = track.added_at ? new Date(track.added_at).toLocaleString(i18n.language) : "—"; + // Two rows rather than one "disc / track": "1 / 37" read as track 1 of + // 37 when it meant disc 1, track 37. const trackNumber = - track.track_number != null - ? track.disc_number != null && track.disc_number > 0 - ? `${track.disc_number} / ${track.track_number}` - : String(track.track_number) + track.track_number != null && track.track_number > 0 + ? String(track.track_number) + : "—"; + const discNumber = + track.disc_number != null && track.disc_number > 0 + ? String(track.disc_number) : "—"; return ( @@ -356,6 +411,20 @@ export function TrackPropertiesModal({ > + {/* Up here rather than in the footer: the footer sits below every + section, a scroll away, and editing is what the dialog is + opened for as often as reading. */} + {!editing && ( + + )} {/* Header — cover + title block */}
@@ -383,7 +452,7 @@ export function TrackPropertiesModal({ )}
-
+
{t("trackProperties.title")}
@@ -398,23 +467,21 @@ export function TrackPropertiesModal({ placeholder={t("trackProperties.fields.title")} className="w-full text-lg font-semibold px-2 py-1 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100" /> - - setForm((p) => ({ ...p, artist: e.target.value })) - } + onChange={(v) => setForm((p) => ({ ...p, artist: v }))} + groups={artistGroups} placeholder={t("trackProperties.fields.artist")} - className="w-full text-sm px-2 py-1 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200" + ariaLabel={t("trackProperties.fields.artist")} + inputClassName="w-full text-sm px-2 py-1 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200" /> - - setForm((p) => ({ ...p, album: e.target.value })) - } + onChange={(v) => setForm((p) => ({ ...p, album: v }))} + groups={albumGroups} placeholder={t("trackProperties.fields.album")} - className="w-full text-sm px-2 py-1 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200" + ariaLabel={t("trackProperties.fields.album")} + inputClassName="w-full text-sm px-2 py-1 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200" />
) : ( @@ -469,18 +536,28 @@ export function TrackPropertiesModal({ onChange={(v) => setForm((p) => ({ ...p, disc_number: v }))} placeholder="1" /> - setForm((p) => ({ ...p, genre: v }))} - placeholder={t("trackProperties.fields.genrePlaceholder")} - // Locked until we know what the track already carries. - // An editable-but-empty box would take a value the save - // then drops on the floor, since a genre we can't - // compare against is a genre we won't send. - disabled={genres == null} - /> +
+ + {t("trackProperties.fields.genre")} + + setForm((p) => ({ ...p, genre: v }))} + // One value, not a list to complete: the save stores + // the string as a single genre (see handleSave), so + // helping to type "Rock; Pop" would make a genre of + // that name. + groups={genreGroups} + placeholder={t("trackProperties.fields.genrePlaceholder")} + ariaLabel={t("trackProperties.fields.genre")} + // Locked until we know what the track already carries. + // An editable-but-empty box would take a value the save + // then drops on the floor, since a genre we can't + // compare against is a genre we won't send. + disabled={genres == null} + inputClassName="w-full px-2 py-1 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-800 dark:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-60" + /> +
+ + {t("trackProperties.showInExplorer")} -