Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
1 change: 1 addition & 0 deletions docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32>` 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 `<canvas>` 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

Expand Down
48 changes: 46 additions & 2 deletions src/components/player/ImmersiveNowPlaying.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -69,6 +73,30 @@ 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,
ready: visualizerColorReady,
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).
Expand Down Expand Up @@ -226,13 +254,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. */}
<SpectrumVisualizer className="w-full h-16 mb-2 opacity-80" glow />
<SpectrumVisualizer
className="w-full h-16 mb-2 opacity-80"
color={visualizerColor}
rainbow={rainbow}
glow
/>
<ProgressBar />
<div className="flex items-center justify-between gap-6 mt-2">
{/* Left cluster — like / station favorite. Lives down here
(not in the hero) so the visualizer canvas above never
sits underneath an interactive control. */}
<div className="flex-1 min-w-0 flex justify-start">
<div className="flex-1 min-w-0 flex justify-start items-center gap-1">
{currentRadioStation ? (
<button
type="button"
Expand Down Expand Up @@ -275,6 +308,17 @@ export function ImmersiveNowPlaying({
<Heart size={20} className={isLiked ? "fill-current" : ""} />
</button>
) : null}
{/* Visualizer colour cycle (issue #468) — only when the
visualizer is on AND the stored colour has loaded, so an
early click can't clobber it with a default-derived value. */}
{visualizerOn && visualizerColorReady && (
<VisualizerColorButton
colorId={colorId}
color={visualizerColor}
rainbow={rainbow}
onCycle={() => void cycle()}
/>
)}
</div>
<PlaybackControls />
<div className="flex-1 min-w-0 flex justify-end">
Expand Down
33 changes: 26 additions & 7 deletions src/components/player/SpectrumVisualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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<HTMLCanvasElement | null>(null);
// Latest bands received from the backend. Mutable ref so the
// animation loop reads the freshest values without re-rendering.
Expand Down Expand Up @@ -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);
Expand All @@ -142,7 +161,7 @@ export function SpectrumVisualizer({
cancelAnimationFrame(raf);
ro.disconnect();
};
}, [color, glow]);
}, [fill, rainbow, glow]);

return (
<canvas
Expand Down
56 changes: 56 additions & 0 deletions src/components/player/VisualizerColorButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next";
import type { VisualizerColorId } from "../../hooks/useVisualizerColor";

/** A representative rainbow swatch for the button when `rainbow` is active. */
const RAINBOW_SWATCH =
"conic-gradient(from 0deg, #ef4444, #f97316, #eab308, #22c55e, #22d3ee, #6366f1, #d946ef, #ef4444)";

interface VisualizerColorButtonProps {
/** Current selection — drives the localized label. */
colorId: VisualizerColorId;
/** Solid CSS fill for the swatch, or `undefined` when `rainbow`. */
color: string | undefined;
/** Whether the current selection is the per-bar rainbow. */
rainbow: boolean;
/** Advance to the next colour (loops). */
onCycle: () => 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 (
<button
type="button"
onClick={onCycle}
aria-label={label}
title={label}
className="p-2 rounded-full text-white/60 transition-colors hover:text-white"
>
<span
className="block rounded-full border border-white/40 shadow-sm"
style={{
width: size,
height: size,
background: rainbow ? RAINBOW_SWATCH : color,
}}
/>
</button>
);
}
Loading