From 904b6525890037f8883e6c8068001679e147617c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 2 Aug 2026 18:59:01 +0200 Subject: [PATCH 1/2] feat(player): user-selectable spectrum visualizer colors (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The immersive spectrum visualizer was a fixed white; jo-el414 asked for a few color choices cyclable from the now-playing screen, since the backdrop is derived from the album art and no single tint reads well over every cover. - `useVisualizerColor` (per-profile `profile_setting['ui.visualizer_color']`, default `white` = the historical `rgba(255,255,255,0.85)`, so existing installs are visually unchanged) cycles White → Emerald → Orange → Aqua → Magenta → Rainbow and loops. Same serialized-write / profile-guard / rollback machinery as `useCoverSlideshow`. - `SpectrumVisualizer` honours the chosen `color` and gains a `rainbow` mode (per-bar 0–300° hue sweep). The old `glow` prop now only picks the default fill when no explicit color is given, so nothing else changes. - `VisualizerColorButton` (a swatch, conic-gradient for rainbow) sits next to the like/★ in `ImmersiveNowPlaying`, shown only when the visualizer toggle is on. Cycles on click — same shape as the repeat-mode button. - i18n `settings.visualizer.cycleColor` + `settings.visualizer.colors.*` ×17. - Docs: playback.md + CLAUDE.md catalogue. --- CLAUDE.md | 2 +- docs/features/playback.md | 1 + src/components/player/ImmersiveNowPlaying.tsx | 42 ++++- src/components/player/SpectrumVisualizer.tsx | 33 +++- .../player/VisualizerColorButton.tsx | 56 +++++++ src/hooks/useVisualizerColor.ts | 157 ++++++++++++++++++ src/i18n/locales/ar.json | 11 +- src/i18n/locales/de.json | 11 +- src/i18n/locales/en.json | 11 +- src/i18n/locales/es.json | 11 +- src/i18n/locales/fr.json | 11 +- src/i18n/locales/hi.json | 11 +- src/i18n/locales/id.json | 11 +- src/i18n/locales/it.json | 11 +- src/i18n/locales/ja.json | 11 +- src/i18n/locales/ko.json | 11 +- src/i18n/locales/nl.json | 11 +- src/i18n/locales/pt-BR.json | 11 +- src/i18n/locales/pt.json | 11 +- src/i18n/locales/ru.json | 11 +- src/i18n/locales/tr.json | 11 +- src/i18n/locales/zh-CN.json | 11 +- src/i18n/locales/zh-TW.json | 11 +- 23 files changed, 451 insertions(+), 27 deletions(-) create mode 100644 src/components/player/VisualizerColorButton.tsx create mode 100644 src/hooks/useVisualizerColor.ts diff --git a/CLAUDE.md b/CLAUDE.md index 16c04e9a..01241797 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ One-liners + doc pointer. For everything else read the actual file in `commands/ ### Playback ([`docs/features/playback.md`](docs/features/playback.md)) -A-B repeat · crossfade (static / smart-album-aware / dynamic-tempo-aware) · gapless · ReplayGain · normalize · mono · 6-band peaking EQ (RBJ biquads, ±12 dB, 20 presets) · playback speed 0.5×–2× (resampler-shift, pitch follows) · DSD → PCM (Blackman-Harris FIR, user-selectable precision: 256-tap default / 1024 / 2048 via Settings → Playback, persisted in `profile_setting['audio.dsd_precision']` → `SharedPlayback.dsd_taps` atomic, read at stream-open by [`DsdToPcm::new_with_taps`](src-tauri/crates/core/src/audio_format/dsd/pcm.rs); DSD-only, symphonia formats ignore it, more taps = sharper transition band at linear CPU cost) · network pre-load (files on a Windows UNC / mapped `DRIVE_REMOTE` drive or a Linux gvfs/SMB mount are read fully into RAM under a 512 MiB cap and decoded from a `Cursor` instead of streamed, avoiding mid-playback stutter on high-latency links — [`ActiveStream::open`](src-tauri/crates/app/src/audio/crossfade.rs); DSD streams as usual) · WASAPI Exclusive opt-in (Windows) with transparent fallback to cpal shared · spectrum visualizer (2048-pt FFT, opt-in) · output device persistence + cpal 0.17 friendly-name disambiguation · radio (seed + similar artists + BPM filter) · mood radio (focus/chill/workout/party/sleep) · sleep timer · embedded lyrics tag read prefers a synced `SYNCEDLYRICS` tag (TXXX / Vorbis comment, e.g. Antra rips) over the plain `USLT`/`LYRICS` keys, with a `TXXX:UNSYNCEDLYRICS` fallback for MP3 K-Pop/J-Pop rips ([`read_embedded_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs)); saving synced (LRC/Enhanced) lyrics re-stamps the canonical `SYNCEDLYRICS` tag via the concrete tag ([`write_synced_lyrics_tag`](src-tauri/crates/app/src/commands/lyrics.rs)) because lofty's generic save drops unmapped frames — otherwise an edit would wipe the original synced tag · sidecar `.lrc` / `.txt` auto-discovery next to the audio file or inside a sibling `Lyrics/` folder (case-insensitive, `.lrc` wins over `.txt`, runs before LRCLIB so no network hit when the rip ships its own lyrics) · **prefer-LRCLIB toggle** (`profile_setting['lyrics.prefer_lrclib']`, default off, Settings → Playback — issue #378): flips the on-demand [`fetch_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs) order so the online providers run before the embedded + sidecar tiers, which become the fallback used only when the network has nothing (a track LRCLIB doesn't carry still shows its own embedded lyrics); the bulk `run_prefetch` gap-filler stays local-first · word-level karaoke lyrics (Enhanced LRC + TTML parse, mot-à-mot capture in the editor) · Web Radio now-playing lyrics ([`fetch_radio_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs) keys a dedicated `radio_lyrics` table in app.db by blake3(artist+title) from the ICY title — a radio session has no library row / file-hash to use the normal `lyrics` cache; queries LRCLIB + the query fallback chain, caches misses as empty rows; the LyricsPanel re-fetches per song (the sentinel track id stays constant across the session, so the effect keys on title+artist) and renders **statically** — synced LRC is timestamp-stripped because the live stream position can't align to a song joined mid-play; library-row mutation actions edit/import/refetch/clear are hidden for radio). +A-B repeat · crossfade (static / smart-album-aware / dynamic-tempo-aware) · gapless · ReplayGain · normalize · mono · 6-band peaking EQ (RBJ biquads, ±12 dB, 20 presets) · playback speed 0.5×–2× (resampler-shift, pitch follows) · DSD → PCM (Blackman-Harris FIR, user-selectable precision: 256-tap default / 1024 / 2048 via Settings → Playback, persisted in `profile_setting['audio.dsd_precision']` → `SharedPlayback.dsd_taps` atomic, read at stream-open by [`DsdToPcm::new_with_taps`](src-tauri/crates/core/src/audio_format/dsd/pcm.rs); DSD-only, symphonia formats ignore it, more taps = sharper transition band at linear CPU cost) · network pre-load (files on a Windows UNC / mapped `DRIVE_REMOTE` drive or a Linux gvfs/SMB mount are read fully into RAM under a 512 MiB cap and decoded from a `Cursor` instead of streamed, avoiding mid-playback stutter on high-latency links — [`ActiveStream::open`](src-tauri/crates/app/src/audio/crossfade.rs); DSD streams as usual) · WASAPI Exclusive opt-in (Windows) with transparent fallback to cpal shared · spectrum visualizer (2048-pt FFT, opt-in; user-selectable bar colour White/Emerald/Orange/Aqua/Magenta/Rainbow cycled from an immersive button, `profile_setting['ui.visualizer_color']`, issue #468) · output device persistence + cpal 0.17 friendly-name disambiguation · radio (seed + similar artists + BPM filter) · mood radio (focus/chill/workout/party/sleep) · sleep timer · embedded lyrics tag read prefers a synced `SYNCEDLYRICS` tag (TXXX / Vorbis comment, e.g. Antra rips) over the plain `USLT`/`LYRICS` keys, with a `TXXX:UNSYNCEDLYRICS` fallback for MP3 K-Pop/J-Pop rips ([`read_embedded_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs)); saving synced (LRC/Enhanced) lyrics re-stamps the canonical `SYNCEDLYRICS` tag via the concrete tag ([`write_synced_lyrics_tag`](src-tauri/crates/app/src/commands/lyrics.rs)) because lofty's generic save drops unmapped frames — otherwise an edit would wipe the original synced tag · sidecar `.lrc` / `.txt` auto-discovery next to the audio file or inside a sibling `Lyrics/` folder (case-insensitive, `.lrc` wins over `.txt`, runs before LRCLIB so no network hit when the rip ships its own lyrics) · **prefer-LRCLIB toggle** (`profile_setting['lyrics.prefer_lrclib']`, default off, Settings → Playback — issue #378): flips the on-demand [`fetch_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs) order so the online providers run before the embedded + sidecar tiers, which become the fallback used only when the network has nothing (a track LRCLIB doesn't carry still shows its own embedded lyrics); the bulk `run_prefetch` gap-filler stays local-first · word-level karaoke lyrics (Enhanced LRC + TTML parse, mot-à-mot capture in the editor) · Web Radio now-playing lyrics ([`fetch_radio_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs) keys a dedicated `radio_lyrics` table in app.db by blake3(artist+title) from the ICY title — a radio session has no library row / file-hash to use the normal `lyrics` cache; queries LRCLIB + the query fallback chain, caches misses as empty rows; the LyricsPanel re-fetches per song (the sentinel track id stays constant across the session, so the effect keys on title+artist) and renders **statically** — synced LRC is timestamp-stripped because the live stream position can't align to a song joined mid-play; library-row mutation actions edit/import/refetch/clear are hidden for radio). ### Library ([`docs/features/library.md`](docs/features/library.md)) diff --git a/docs/features/playback.md b/docs/features/playback.md index f54c315a..1b1258e4 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -17,6 +17,7 @@ Real-time FFT bars surfaced in the immersive Now Playing overlay. Implementation - Output is a `player:spectrum` Tauri event carrying a `Vec` of normalised band magnitudes (0..1, peaks may briefly overshoot). - 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 making the bars look glitchy. Auto-fades to zero on pause so the bars don't freeze mid-pose. +- 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. ## Crossfade diff --git a/src/components/player/ImmersiveNowPlaying.tsx b/src/components/player/ImmersiveNowPlaying.tsx index 0a8ba930..dbbc9ba0 100644 --- a/src/components/player/ImmersiveNowPlaying.tsx +++ b/src/components/player/ImmersiveNowPlaying.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Heart, Star, Radio } from "lucide-react"; import { Artwork } from "../common/Artwork"; @@ -10,7 +11,10 @@ import { PlaybackControls } from "./PlaybackControls"; import { ProgressBar } from "./ProgressBar"; import { VolumeControl } from "./VolumeControl"; import { SpectrumVisualizer } from "./SpectrumVisualizer"; +import { VisualizerColorButton } from "./VisualizerColorButton"; import { usePlayer } from "../../hooks/usePlayer"; +import { useVisualizerColor } from "../../hooks/useVisualizerColor"; +import { getVisualizerEnabled } from "../../lib/tauri/visualizer"; import { useWebRadioFavorites } from "../../hooks/useWebRadioFavorites"; import { usePlayerTrackContextMenu } from "../../hooks/usePlayerTrackContextMenu"; import { useTrackCanvas } from "../../hooks/useTrackCanvas"; @@ -69,6 +73,25 @@ export function ImmersiveNowPlaying({ currentRadioStation != null && radioFavorites.isFavorite(currentRadioStation.id); + // Spectrum-visualizer colour (issue #468). The cycle button only shows when + // the visualizer itself is enabled (a per-profile backend toggle read once + // on mount — the immersive view remounts each time it's opened). The chosen + // colour feeds the visualizer's fill; `rainbow` tints per bar. + const { colorId, color: visualizerColor, rainbow, cycle } = + useVisualizerColor(); + const [visualizerOn, setVisualizerOn] = useState(false); + useEffect(() => { + let cancelled = false; + getVisualizerEnabled() + .then((on) => { + if (!cancelled) setVisualizerOn(on); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + // Per-track Canvas (issue #442) — a looping clip replaces the static cover // when one is set, the global toggle is on, and motion isn't reduced. It // takes precedence over the plugin motion cover (Canvas > motion > cover). @@ -226,13 +249,18 @@ export function ImmersiveNowPlaying({ nothing when the backend toggle is off, so it's safe to always mount. `glow` = white bars suited to the dim backdrop. */} - +
{/* Left cluster — like / station favorite. Lives down here (not in the hero) so the visualizer canvas above never sits underneath an interactive control. */} -
+
{currentRadioStation ? ( ) : null} + {/* Visualizer colour cycle (issue #468) — only when the + visualizer is on, so it isn't a dead control otherwise. */} + {visualizerOn && ( + void cycle()} + /> + )}
diff --git a/src/components/player/SpectrumVisualizer.tsx b/src/components/player/SpectrumVisualizer.tsx index 79642c83..06c84083 100644 --- a/src/components/player/SpectrumVisualizer.tsx +++ b/src/components/player/SpectrumVisualizer.tsx @@ -8,12 +8,20 @@ interface SpectrumPayload { interface SpectrumVisualizerProps { /** Tailwind sizing classes for the canvas wrapper. */ className?: string; - /** Bar fill colour (any CSS colour the canvas accepts). */ + /** + * Bar fill colour (any CSS colour the canvas accepts). When omitted, falls + * back to the light backdrop variant (see `glow`). Ignored when `rainbow`. + */ color?: string; /** - * When true, draws a light-on-dark variant suited to the immersive - * overlay backdrop (white bars at moderate opacity). When false, - * uses the regular `color` prop. + * When true, tints each bar by a per-index hue (a spectrum sweep) instead of + * a solid fill — overrides `color`. Issue #468. + */ + rainbow?: boolean; + /** + * When true and no explicit `color` is given, draws the light-on-dark + * variant suited to the immersive overlay backdrop (white bars at moderate + * opacity). An explicit `color`/`rainbow` always wins. */ glow?: boolean; } @@ -32,9 +40,13 @@ interface SpectrumVisualizerProps { */ export function SpectrumVisualizer({ className = "w-full h-24", - color = "#10b981", + color, + rainbow = false, glow = false, }: SpectrumVisualizerProps) { + // An explicit colour wins; otherwise fall back to the immersive light + // variant (`glow`) or the historical emerald default. + const fill = color ?? (glow ? "rgba(255,255,255,0.85)" : "#10b981"); const canvasRef = useRef(null); // Latest bands received from the backend. Mutable ref so the // animation loop reads the freshest values without re-rendering. @@ -124,8 +136,15 @@ export function SpectrumVisualizer({ const gap = Math.max(1, Math.floor(w / barCount / 4)); const barWidth = Math.max(1, (w - gap * (barCount - 1)) / barCount); - ctx.fillStyle = glow ? "rgba(255,255,255,0.85)" : color; + // Solid fill is set once; rainbow re-tints per bar by index hue. + if (!rainbow) ctx.fillStyle = fill; + const denom = Math.max(1, barCount - 1); for (let i = 0; i < barCount; i++) { + if (rainbow) { + // 0..300° sweep (red → violet) — stop short of 360 so the last + // bar doesn't wrap back to the first's red. + ctx.fillStyle = `hsl(${(i / denom) * 300}, 85%, 60%)`; + } const value = Math.max(0, Math.min(1, drawn[i])); const barHeight = value * h; const x = i * (barWidth + gap); @@ -142,7 +161,7 @@ export function SpectrumVisualizer({ cancelAnimationFrame(raf); ro.disconnect(); }; - }, [color, glow]); + }, [fill, rainbow, glow]); return ( void; + size?: number; +} + +/** + * Immersive-view control that cycles the spectrum-visualizer colour + * (issue #468). The swatch shows the current colour (a conic gradient for + * rainbow); clicking advances to the next and loops back to the default — + * same "advance-through-a-fixed-list" shape as the repeat-mode button, so the + * user can find a tint that reads well over the album-derived backdrop. + */ +export function VisualizerColorButton({ + colorId, + color, + rainbow, + onCycle, + size = 20, +}: VisualizerColorButtonProps) { + const { t } = useTranslation(); + const label = t("settings.visualizer.cycleColor", { + color: t(`settings.visualizer.colors.${colorId}`), + }); + return ( + + ); +} diff --git a/src/hooks/useVisualizerColor.ts b/src/hooks/useVisualizerColor.ts new file mode 100644 index 00000000..d41a7326 --- /dev/null +++ b/src/hooks/useVisualizerColor.ts @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getProfileSetting, setProfileSetting } from "../lib/tauri/profile"; +import { useProfile } from "./useProfile"; + +const KEY = "ui.visualizer_color"; + +/** Broadcast after a successful write so every mounted consumer (the + * immersive view's cycle button + the visualizer itself) re-reads in one go. */ +export const VISUALIZER_COLOR_EVENT = "waveflow:visualizer-color"; + +/** + * The selectable spectrum-visualizer colours (issue #468). `white` is the + * default and reproduces the pre-existing look; `rainbow` tints each bar by a + * per-index hue instead of a solid fill. The button cycles through them in + * this order and loops back to `white`. + */ +export type VisualizerColorId = + | "white" + | "emerald" + | "orange" + | "aqua" + | "magenta" + | "rainbow"; + +/** Cycle order for the button — advancing past the last wraps to the first. */ +export const VISUALIZER_COLOR_ORDER: VisualizerColorId[] = [ + "white", + "emerald", + "orange", + "aqua", + "magenta", + "rainbow", +]; + +/** + * Resolved CSS fill per solid colour. `white` keeps the historical + * `rgba(255,255,255,0.85)` so existing users see zero change on first run; + * `rainbow` has no entry here — it's drawn per-bar by the visualizer. + */ +export const VISUALIZER_COLOR_CSS: Record< + Exclude, + string +> = { + white: "rgba(255,255,255,0.85)", + emerald: "#10b981", + orange: "#f97316", + aqua: "#22d3ee", + magenta: "#d946ef", +}; + +const DEFAULT_COLOR: VisualizerColorId = "white"; + +function parseColorId(raw: string | null): VisualizerColorId { + if (raw != null && (VISUALIZER_COLOR_ORDER as string[]).includes(raw)) { + return raw as VisualizerColorId; + } + return DEFAULT_COLOR; +} + +export interface VisualizerColor { + /** Current selection. */ + colorId: VisualizerColorId; + /** Solid CSS fill, or `undefined` when `rainbow` (drawn per-bar). */ + color: string | undefined; + /** Whether the current selection is the per-bar rainbow. */ + rainbow: boolean; + /** Advance to the next colour in {@link VISUALIZER_COLOR_ORDER}, wrapping. */ + cycle: () => Promise; +} + +/** + * Per-profile preference: the spectrum-visualizer bar colour (issue #468). + * Default `white` (identical to the previous fixed look). Read by the + * immersive now-playing surface — both the visualizer (for the fill) and the + * cycle button (for the current label). The write machinery mirrors + * [`useCoverSlideshow`](./useCoverSlideshow.ts) — serialized writes, + * profile-switch guards, and rollback to the last backend-confirmed value. + */ +export function useVisualizerColor(): VisualizerColor { + const { activeProfile } = useProfile(); + const [colorId, setColorIdState] = useState(DEFAULT_COLOR); + const colorIdRef = useRef(colorId); + const confirmedRef = useRef(colorId); + const writeChainRef = useRef>(Promise.resolve()); + const writeSeqRef = useRef(0); + const activeProfileIdRef = useRef(activeProfile?.id ?? null); + useEffect(() => { + colorIdRef.current = colorId; + }, [colorId]); + useEffect(() => { + activeProfileIdRef.current = activeProfile?.id ?? null; + }, [activeProfile?.id]); + + useEffect(() => { + let cancelled = false; + const refresh = async () => { + try { + const raw = await getProfileSetting(KEY); + if (cancelled) return; + const parsed = parseColorId(raw); + colorIdRef.current = parsed; + confirmedRef.current = parsed; + setColorIdState(parsed); + } catch (err) { + console.error("[useVisualizerColor] read failed", err); + } + }; + void refresh(); + window.addEventListener(VISUALIZER_COLOR_EVENT, refresh); + return () => { + cancelled = true; + window.removeEventListener(VISUALIZER_COLOR_EVENT, refresh); + }; + }, [activeProfile?.id]); + + const setColorId = useCallback(async (next: VisualizerColorId) => { + const seq = ++writeSeqRef.current; + const profileId = activeProfileIdRef.current; + colorIdRef.current = next; + setColorIdState(next); + const write = writeChainRef.current.then(async () => { + if (activeProfileIdRef.current !== profileId) return; + await setProfileSetting(KEY, next, "string"); + if (activeProfileIdRef.current !== profileId) return; + confirmedRef.current = next; + }); + writeChainRef.current = write.catch(() => undefined); + try { + await write; + if (activeProfileIdRef.current !== profileId) return; + if (seq !== writeSeqRef.current) return; + window.dispatchEvent(new CustomEvent(VISUALIZER_COLOR_EVENT)); + } catch (err) { + console.error("[useVisualizerColor] write failed", err); + if (activeProfileIdRef.current !== profileId) return; + if (seq !== writeSeqRef.current) return; + const rollback = confirmedRef.current; + colorIdRef.current = rollback; + setColorIdState(rollback); + } + }, []); + + const cycle = useCallback(async () => { + const idx = VISUALIZER_COLOR_ORDER.indexOf(colorIdRef.current); + const next = + VISUALIZER_COLOR_ORDER[(idx + 1) % VISUALIZER_COLOR_ORDER.length]; + await setColorId(next); + }, [setColorId]); + + const rainbow = colorId === "rainbow"; + return { + colorId, + color: rainbow ? undefined : VISUALIZER_COLOR_CSS[colorId], + rainbow, + cycle, + }; +} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 99dbd972..8c3fb309 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1812,7 +1812,16 @@ }, "visualizer": { "title": "محلل الصوت البصري", - "subtitle": "يعرض طيفًا في الوقت الفعلي في العرض الغامر (FFT خفيف على خيط فك التشفير)" + "subtitle": "يعرض طيفًا في الوقت الفعلي في العرض الغامر (FFT خفيف على خيط فك التشفير)", + "cycleColor": "لون المُصوّر: {{color}}", + "colors": { + "white": "أبيض", + "emerald": "زمردي", + "orange": "برتقالي", + "aqua": "سماوي", + "magenta": "أرجواني", + "rainbow": "قوس قزح" + } }, "smartCrossfade": { "title": "المزج المتقاطع الذكي", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 652d50ce..1947d29e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1673,7 +1673,16 @@ }, "visualizer": { "title": "Audio-Visualizer", - "subtitle": "Zeigt ein Echtzeit-Spektrum in der immersiven Ansicht (leichtgewichtige FFT im Decoder-Thread)" + "subtitle": "Zeigt ein Echtzeit-Spektrum in der immersiven Ansicht (leichtgewichtige FFT im Decoder-Thread)", + "cycleColor": "Visualizer-Farbe: {{color}}", + "colors": { + "white": "Weiß", + "emerald": "Smaragd", + "orange": "Orange", + "aqua": "Aqua", + "magenta": "Magenta", + "rainbow": "Regenbogen" + } }, "smartCrossfade": { "title": "Smartes Crossfade", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9ed76571..440e6160 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1673,7 +1673,16 @@ }, "visualizer": { "title": "Audio visualizer", - "subtitle": "Show a real-time spectrum in the immersive view (lightweight FFT on the decoder thread)" + "subtitle": "Show a real-time spectrum in the immersive view (lightweight FFT on the decoder thread)", + "cycleColor": "Visualizer color: {{color}}", + "colors": { + "white": "White", + "emerald": "Emerald", + "orange": "Orange", + "aqua": "Aqua", + "magenta": "Magenta", + "rainbow": "Rainbow" + } }, "smartCrossfade": { "title": "Smart crossfade", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 72948c7a..329bb671 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1673,7 +1673,16 @@ }, "visualizer": { "title": "Visualizador de audio", - "subtitle": "Muestra un espectro en tiempo real en la vista inmersiva (FFT ligero en el hilo del decodificador)" + "subtitle": "Muestra un espectro en tiempo real en la vista inmersiva (FFT ligero en el hilo del decodificador)", + "cycleColor": "Color del visualizador: {{color}}", + "colors": { + "white": "Blanco", + "emerald": "Esmeralda", + "orange": "Naranja", + "aqua": "Aguamarina", + "magenta": "Magenta", + "rainbow": "Arcoíris" + } }, "smartCrossfade": { "title": "Crossfade inteligente", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c5286989..1e2e7c40 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1759,7 +1759,16 @@ }, "visualizer": { "title": "Visualiseur audio", - "subtitle": "Affiche un spectre temps réel dans la vue immersive (analyse FFT légère sur le décodeur)" + "subtitle": "Affiche un spectre temps réel dans la vue immersive (analyse FFT légère sur le décodeur)", + "cycleColor": "Couleur du visualiseur : {{color}}", + "colors": { + "white": "Blanc", + "emerald": "Émeraude", + "orange": "Orange", + "aqua": "Aqua", + "magenta": "Magenta", + "rainbow": "Arc-en-ciel" + } }, "smartCrossfade": { "title": "Fondu intelligent", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 0abc407f..d6d1c8e7 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1537,7 +1537,16 @@ }, "visualizer": { "title": "ऑडियो विज़ुअलाइज़र", - "subtitle": "इमर्सिव व्यू में रीयल-टाइम स्पेक्ट्रम दिखाएँ (डिकोडर थ्रेड पर हल्का FFT)" + "subtitle": "इमर्सिव व्यू में रीयल-टाइम स्पेक्ट्रम दिखाएँ (डिकोडर थ्रेड पर हल्का FFT)", + "cycleColor": "विज़ुअलाइज़र रंग: {{color}}", + "colors": { + "white": "सफ़ेद", + "emerald": "पन्ना", + "orange": "नारंगी", + "aqua": "आसमानी", + "magenta": "मैजेंटा", + "rainbow": "इंद्रधनुष" + } }, "duplicates": { "title": "डुप्लिकेट का पता लगाएँ", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index cac770f8..b56629b9 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1673,7 +1673,16 @@ }, "visualizer": { "title": "Visualizer audio", - "subtitle": "Tampilkan spektrum waktu nyata di tampilan imersif (FFT ringan di thread decoder)" + "subtitle": "Tampilkan spektrum waktu nyata di tampilan imersif (FFT ringan di thread decoder)", + "cycleColor": "Warna visualizer: {{color}}", + "colors": { + "white": "Putih", + "emerald": "Zamrud", + "orange": "Oranye", + "aqua": "Akua", + "magenta": "Magenta", + "rainbow": "Pelangi" + } }, "smartCrossfade": { "title": "Crossfade pintar", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index f9affad6..856c1fc5 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1673,7 +1673,16 @@ }, "visualizer": { "title": "Visualizzatore audio", - "subtitle": "Mostra uno spettro in tempo reale nella vista immersiva (FFT leggera sul thread del decoder)" + "subtitle": "Mostra uno spettro in tempo reale nella vista immersiva (FFT leggera sul thread del decoder)", + "cycleColor": "Colore del visualizzatore: {{color}}", + "colors": { + "white": "Bianco", + "emerald": "Smeraldo", + "orange": "Arancione", + "aqua": "Acqua", + "magenta": "Magenta", + "rainbow": "Arcobaleno" + } }, "smartCrossfade": { "title": "Crossfade intelligente", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 951aa7ca..6f9d697f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1794,7 +1794,16 @@ }, "visualizer": { "title": "オーディオビジュアライザー", - "subtitle": "没入ビューでリアルタイムのスペクトラムを表示 (デコーダースレッド上の軽量 FFT)" + "subtitle": "没入ビューでリアルタイムのスペクトラムを表示 (デコーダースレッド上の軽量 FFT)", + "cycleColor": "ビジュアライザーの色: {{color}}", + "colors": { + "white": "ホワイト", + "emerald": "エメラルド", + "orange": "オレンジ", + "aqua": "アクア", + "magenta": "マゼンタ", + "rainbow": "レインボー" + } }, "smartCrossfade": { "title": "スマートクロスフェード", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 24b0535b..690e0600 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1537,7 +1537,16 @@ }, "visualizer": { "title": "오디오 비주얼라이저", - "subtitle": "몰입 모드에서 실시간 스펙트럼을 표시합니다 (디코더 스레드의 경량 FFT)" + "subtitle": "몰입 모드에서 실시간 스펙트럼을 표시합니다 (디코더 스레드의 경량 FFT)", + "cycleColor": "비주얼라이저 색상: {{color}}", + "colors": { + "white": "화이트", + "emerald": "에메랄드", + "orange": "오렌지", + "aqua": "아쿠아", + "magenta": "마젠타", + "rainbow": "무지개" + } }, "duplicates": { "title": "중복 파일 감지", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 5cb5626f..f748fe86 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1673,7 +1673,16 @@ }, "visualizer": { "title": "Audio-visualizer", - "subtitle": "Toont een real-time spectrum in de meeslepende weergave (lichte FFT op de decoderthread)" + "subtitle": "Toont een real-time spectrum in de meeslepende weergave (lichte FFT op de decoderthread)", + "cycleColor": "Visualizerkleur: {{color}}", + "colors": { + "white": "Wit", + "emerald": "Smaragd", + "orange": "Oranje", + "aqua": "Aqua", + "magenta": "Magenta", + "rainbow": "Regenboog" + } }, "smartCrossfade": { "title": "Slimme crossfade", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index d3505603..d1064579 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1537,7 +1537,16 @@ }, "visualizer": { "title": "Visualizador de áudio", - "subtitle": "Mostra um espectro em tempo real na visão imersiva (FFT leve na thread do decodificador)" + "subtitle": "Mostra um espectro em tempo real na visão imersiva (FFT leve na thread do decodificador)", + "cycleColor": "Cor do visualizador: {{color}}", + "colors": { + "white": "Branco", + "emerald": "Esmeralda", + "orange": "Laranja", + "aqua": "Ciano", + "magenta": "Magenta", + "rainbow": "Arco-íris" + } }, "duplicates": { "title": "Detectar duplicatas", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index b7e30564..397ff8e5 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1537,7 +1537,16 @@ }, "visualizer": { "title": "Visualizador de áudio", - "subtitle": "Mostra um espetro em tempo real na vista imersiva (FFT leve na thread de descodificação)" + "subtitle": "Mostra um espetro em tempo real na vista imersiva (FFT leve na thread de descodificação)", + "cycleColor": "Cor do visualizador: {{color}}", + "colors": { + "white": "Branco", + "emerald": "Esmeralda", + "orange": "Laranja", + "aqua": "Ciano", + "magenta": "Magenta", + "rainbow": "Arco-íris" + } }, "duplicates": { "title": "Detetar duplicados", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8bb52bfb..18c8df79 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1761,7 +1761,16 @@ }, "visualizer": { "title": "Аудио-визуализатор", - "subtitle": "Показывает спектр в реальном времени в иммерсивном режиме (лёгкий FFT в потоке декодера)" + "subtitle": "Показывает спектр в реальном времени в иммерсивном режиме (лёгкий FFT в потоке декодера)", + "cycleColor": "Цвет визуализатора: {{color}}", + "colors": { + "white": "Белый", + "emerald": "Изумрудный", + "orange": "Оранжевый", + "aqua": "Аквамарин", + "magenta": "Пурпурный", + "rainbow": "Радуга" + } }, "smartCrossfade": { "title": "Умный кроссфейд", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 6a528987..5319202e 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1673,7 +1673,16 @@ }, "visualizer": { "title": "Ses görselleştirici", - "subtitle": "Sürükleyici görünümde gerçek zamanlı spektrumu gösterir (kod çözücü iş parçacığında hafif FFT)" + "subtitle": "Sürükleyici görünümde gerçek zamanlı spektrumu gösterir (kod çözücü iş parçacığında hafif FFT)", + "cycleColor": "Görselleştirici rengi: {{color}}", + "colors": { + "white": "Beyaz", + "emerald": "Zümrüt", + "orange": "Turuncu", + "aqua": "Camgöbeği", + "magenta": "Macenta", + "rainbow": "Gökkuşağı" + } }, "smartCrossfade": { "title": "Akıllı çapraz geçiş", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3e3636d0..830d63cd 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1794,7 +1794,16 @@ }, "visualizer": { "title": "音频可视化", - "subtitle": "在沉浸式视图中显示实时频谱(解码器线程上的轻量 FFT)" + "subtitle": "在沉浸式视图中显示实时频谱(解码器线程上的轻量 FFT)", + "cycleColor": "可视化颜色:{{color}}", + "colors": { + "white": "白色", + "emerald": "翡翠绿", + "orange": "橙色", + "aqua": "水蓝色", + "magenta": "品红色", + "rainbow": "彩虹" + } }, "smartCrossfade": { "title": "智能交叉淡化", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 26848619..fa16af37 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1794,7 +1794,16 @@ }, "visualizer": { "title": "音訊視覺化", - "subtitle": "在沉浸式檢視中顯示即時頻譜(解碼器執行緒上的輕量 FFT)" + "subtitle": "在沉浸式檢視中顯示即時頻譜(解碼器執行緒上的輕量 FFT)", + "cycleColor": "視覺化顏色:{{color}}", + "colors": { + "white": "白色", + "emerald": "翡翠綠", + "orange": "橙色", + "aqua": "水藍色", + "magenta": "洋紅色", + "rainbow": "彩虹" + } }, "smartCrossfade": { "title": "智慧交叉淡化", From 858b20679ab60c5a391d3810de9f839b936e38f1 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 2 Aug 2026 19:16:49 +0200 Subject: [PATCH 2/2] fix(player): gate visualizer color cycle until the profile's value loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round on #481. Between mount/profile-switch and the async `getProfileSetting` resolving, `colorId` is the placeholder default; a click in that window would cycle from the default and persist it over the real stored colour. Expose a `ready` flag and refuse to act until it's true. Readiness is tracked as the profile id the value was loaded for (`ready` is derived by comparing it to the active profile), so a profile switch makes the hook not-ready again with no synchronous setState — avoids the `react-hooks/set-state-in-effect` lint and re-hides the button automatically. `cycle` refuses when the loaded-for profile != active profile, so a stale read from the previous profile can't authorize a write into the new one. The immersive button renders only when `ready`. --- src/components/player/ImmersiveNowPlaying.tsx | 14 +++++--- src/hooks/useVisualizerColor.ts | 36 ++++++++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/components/player/ImmersiveNowPlaying.tsx b/src/components/player/ImmersiveNowPlaying.tsx index dbbc9ba0..3114286c 100644 --- a/src/components/player/ImmersiveNowPlaying.tsx +++ b/src/components/player/ImmersiveNowPlaying.tsx @@ -77,8 +77,13 @@ export function ImmersiveNowPlaying({ // the visualizer itself is enabled (a per-profile backend toggle read once // on mount — the immersive view remounts each time it's opened). The chosen // colour feeds the visualizer's fill; `rainbow` tints per bar. - const { colorId, color: visualizerColor, rainbow, cycle } = - useVisualizerColor(); + const { + colorId, + color: visualizerColor, + rainbow, + ready: visualizerColorReady, + cycle, + } = useVisualizerColor(); const [visualizerOn, setVisualizerOn] = useState(false); useEffect(() => { let cancelled = false; @@ -304,8 +309,9 @@ export function ImmersiveNowPlaying({ ) : null} {/* Visualizer colour cycle (issue #468) — only when the - visualizer is on, so it isn't a dead control otherwise. */} - {visualizerOn && ( + visualizer is on AND the stored colour has loaded, so an + early click can't clobber it with a default-derived value. */} + {visualizerOn && visualizerColorReady && ( Promise; } @@ -79,6 +89,11 @@ export interface VisualizerColor { export function useVisualizerColor(): VisualizerColor { const { activeProfile } = useProfile(); const [colorId, setColorIdState] = useState(DEFAULT_COLOR); + // Which profile the stored value has been loaded for. `ready` is derived by + // comparing it to the active profile, so a profile switch makes the hook + // not-ready again with no synchronous setState (the render just recomputes). + const [readyProfileId, setReadyProfileId] = useState(null); + const readyProfileIdRef = useRef(null); const colorIdRef = useRef(colorId); const confirmedRef = useRef(colorId); const writeChainRef = useRef>(Promise.resolve()); @@ -93,6 +108,7 @@ export function useVisualizerColor(): VisualizerColor { useEffect(() => { let cancelled = false; + const profileId = activeProfile?.id ?? null; const refresh = async () => { try { const raw = await getProfileSetting(KEY); @@ -101,6 +117,11 @@ export function useVisualizerColor(): VisualizerColor { colorIdRef.current = parsed; confirmedRef.current = parsed; setColorIdState(parsed); + // Mark ready for THIS profile only after a successful read+parse; a + // failure leaves the previous readiness untouched (stays not-ready on + // first load, so `cycle` refuses until the stored value is known). + readyProfileIdRef.current = profileId; + setReadyProfileId(profileId); } catch (err) { console.error("[useVisualizerColor] read failed", err); } @@ -141,6 +162,16 @@ export function useVisualizerColor(): VisualizerColor { }, []); const cycle = useCallback(async () => { + // Refuse until the stored value has loaded for the ACTIVE profile — + // otherwise we'd cycle from the placeholder default and clobber the + // persisted colour (and a stale read from the previous profile mustn't + // authorize a write into the new one). + if ( + readyProfileIdRef.current === null || + readyProfileIdRef.current !== activeProfileIdRef.current + ) { + return; + } const idx = VISUALIZER_COLOR_ORDER.indexOf(colorIdRef.current); const next = VISUALIZER_COLOR_ORDER[(idx + 1) % VISUALIZER_COLOR_ORDER.length]; @@ -148,10 +179,13 @@ export function useVisualizerColor(): VisualizerColor { }, [setColorId]); const rainbow = colorId === "rainbow"; + const ready = + readyProfileId !== null && readyProfileId === (activeProfile?.id ?? null); return { colorId, color: rainbow ? undefined : VISUALIZER_COLOR_CSS[colorId], rainbow, + ready, cycle, }; }