diff --git a/docs/features/library.md b/docs/features/library.md index 67faadb6..92b6a83d 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. Genres are few and loaded whole, then filtered as you type (accent- and case-insensitive); artists and albums can run to thousands, so they come from the backend's `search_artists` / `search_albums` (bounded, prefix-first, the top bar's search) after a 150 ms pause, cached per query for the life of the dialog and shown as returned, since that search matches a normalised form the list would not. For the genre, the list adds 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 cf701b93..9352105f 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 6f3f98bc..d5597a1b 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 00000000..3c65bda6 --- /dev/null +++ b/src/components/common/TagCombobox.tsx @@ -0,0 +1,280 @@ +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[]; + /** + * Already matched against the input by whoever supplied them (a search + * on the backend), so shown as they come. Filtering them again here + * would drop some: the backend compares a normalised form in which + * "ac dc" finds "AC/DC", and this list would not. + */ + matched?: boolean; +} + +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 (g.matched || 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 1029e60a..a91cb5f9 100644 --- a/src/components/common/TrackPropertiesModal.tsx +++ b/src/components/common/TrackPropertiesModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { @@ -24,6 +24,13 @@ import { type TrackEdit, } from "../../lib/tauri/track"; import { StarRating } from "./StarRating"; +import { TagCombobox, type SuggestionGroup } from "./TagCombobox"; +import { + listGenres, + searchAlbums, + searchArtists, +} 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 +165,60 @@ 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. A failed lookup only means fewer suggestions. + // + // Genres are few, so the whole list is loaded the first time the form + // opens (not with the dialog: most openings only look). Artists and + // albums can run to thousands, so they are searched on the backend as + // the user types — the same bounded, prefix-first search the top bar + // uses — rather than all loaded to be filtered here. + const [knownGenres, setKnownGenres] = useState(null); + const wantsGenres = editing && knownGenres == null; + useEffect(() => { + if (!wantsGenres) return; + let cancelled = false; + listGenres(null) + .then((rows) => { + if (cancelled) return; + const names = new Set( + rows.map((g) => g.name).filter((n) => n.trim() !== ""), + ); + setKnownGenres([...names].sort((a, b) => a.localeCompare(b))); + }) + .catch((err) => { + console.error("[TrackProperties] list_genres failed", err); + if (!cancelled) setKnownGenres([]); + }); + return () => { + cancelled = true; + }; + }, [wantsGenres]); + + const artistHits = useSearchHits( + editing ? form.artist : "", + (q) => searchArtists(q, null, SUGGESTION_LIMIT), + (a) => a.name, + ); + const albumHits = useSearchHits( + editing ? form.album : "", + (q) => searchAlbums(q, null, SUGGESTION_LIMIT), + (a) => a.title, + ); + + const inLibrary = t("trackProperties.suggestions.library"); + const genreGroups: SuggestionGroup[] = [ + { label: inLibrary, values: knownGenres ?? [] }, + { label: t("trackProperties.suggestions.presets"), values: GENRE_PRESETS }, + ]; + const artistGroups: SuggestionGroup[] = [ + { label: inLibrary, values: artistHits, matched: true }, + ]; + const albumGroups: SuggestionGroup[] = [ + { label: inLibrary, values: albumHits, matched: true }, + ]; + // 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 +383,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 +421,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 +462,7 @@ export function TrackPropertiesModal({ )}
-
+
{t("trackProperties.title")}
@@ -398,23 +477,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 +546,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")} -