diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index a032236e..b24dd91d 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -38,6 +38,10 @@ AUDIO - Equalizer boosts no longer distort: the music equalizer turns the whole curve down by its highest boost before the filters, and a soft limiter bends any peak that still goes past full scale. - Loudness normalization for music can now be turned off in the equalizer panel. It stays on by default, as on Android. - Downloaded songs keep their loudness value, so they play at the same level as streamed songs instead of louder. Songs downloaded before this update keep playing at full level. +- A new equalizer, the same as Flow for Android's: graphic and parametric modes, a response graph you can drag, per-band type, frequency, gain and Q, hold to compare, auto preamp or a manual preamp, and bass boost. +- Android's 13 presets with translated names, your own presets with Save, Save as, Rename, Duplicate and Delete with Undo, and a Custom entry that keeps the curve you tuned when you try a preset. AutoEQ and EqualizerAPO files import from a file or pasted text and export to a file or the clipboard. +- The equalizer is saved with your settings, included in backups and synced with Flow for Android. Your old equalizer gains carry over, with the 32 Hz and 16 kHz bands now peaks like on Android, and the panel says when audio processing is not available on your system. +- Music only goes through audio processing once the equalizer changes the sound, and playback resumes on its own after sleep or an output device change. MACOS - Flow uses the native macOS window controls and titlebar again. Closing the window hides it and keeps playback and downloads running until you quit, and clicking the Dock icon brings it back. diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index e0d631ca..68f864e8 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -20,13 +20,14 @@ pub async fn write_backup_file(path: String, contents: String) -> Result<(), Err } let path = PathBuf::from(trimmed_path); - let is_json_path = path + // Backups are JSON; an equalizer export is EqualizerAPO text. + let is_allowed_path = path .extension() .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("json")); - if !is_json_path { + .is_some_and(|ext| ext.eq_ignore_ascii_case("json") || ext.eq_ignore_ascii_case("txt")); + if !is_allowed_path { return Err(ErrorResponse::from(AppError::Validation( - "Backup path must end in .json".into(), + "Export path must end in .json or .txt".into(), ))); } diff --git a/src-tauri/src/sync/settings_map.rs b/src-tauri/src/sync/settings_map.rs index be03c14b..07321027 100644 --- a/src-tauri/src/sync/settings_map.rs +++ b/src-tauri/src/sync/settings_map.rs @@ -83,6 +83,8 @@ const SHARED: &[Shared] = &[ "preferred_subtitle_language", Kind::Text, ), + // The whole equalizer as Android's `EqStateJson`, carried as one JSON string. + shared("equalizer", "eq_state_json", Kind::Text), ]; /// Player, content and extension preferences only the desktop has. Download paths, proxy diff --git a/src-tauri/tests/sync_settings.rs b/src-tauri/tests/sync_settings.rs index a2b006dc..ab193c80 100644 --- a/src-tauri/tests/sync_settings.rs +++ b/src-tauri/tests/sync_settings.rs @@ -76,7 +76,8 @@ async fn android_settings_land_on_the_desktop_keys() { entry("return_youtube_dislikes", json!(true), 1_000), entry("subscriptions_show_shorts", json!(false), 1_000), entry("default_video_codec", json!("av1"), 1_000), - entry("equalizer", json!("{\"bands\":[]}"), 1_000), + entry("equalizer", json!("{\"enabled\":false}"), 1_000), + entry("queue_autoplay", json!(false), 1_000), entry("desktop.subtitle_font_scale", json!("150"), 1_000), ]; apply_payload(&pool, OUR, PEER, &[staged(&incoming, "s1")]) @@ -106,7 +107,12 @@ async fn android_settings_land_on_the_desktop_keys() { "codec never syncs" ); assert_eq!( - stored(&pool, "equalizer").await, + stored(&pool, "eq_state_json").await.as_deref(), + Some("{\"enabled\":false}"), + "the equalizer JSON arrives as Android wrote it" + ); + assert_eq!( + stored(&pool, "queue_autoplay").await, None, "Android-only keys are ignored" ); @@ -137,6 +143,7 @@ async fn export_renames_and_types_settings_and_leaves_the_codec_out() { set_like_the_app(&pool, "subtitle_font_scale", "150").await; set_like_the_app(&pool, "default_video_codec", "av1").await; set_like_the_app(&pool, "download_location", "/secret").await; + set_like_the_app(&pool, "eq_state_json", "{\"version\":1,\"enabled\":true}").await; let out = export_collections(&pool, OUR, &[Collection::Settings]) .await @@ -156,6 +163,11 @@ async fn export_renames_and_types_settings_and_leaves_the_codec_out() { ); assert!(find("default_video_codec").is_none()); assert!(find("download_location").is_none()); + // Android's SettingsMapper reads the equalizer as one string primitive under this key. + assert_eq!( + find("equalizer").unwrap().value, + json!("{\"version\":1,\"enabled\":true}") + ); assert!( entries.iter().all(|e| e.hlc.physical_ms > 0), "local rows carry their real change time" diff --git a/src/App.tsx b/src/App.tsx index 3de7af1f..83e74367 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ import { ToastHost } from "./components/ui/ToastHost"; import { GlobalMusicAudio } from "./components/music/GlobalMusicAudio"; import { GlobalMusicDock } from "./components/music/GlobalMusicDock"; import { MusicOverlay } from "./components/music/MusicOverlay"; +import { EqualizerDialog } from "./components/music/eq/EqualizerDialog"; import { GlobalVideoPlayer } from "./components/watch/GlobalVideoPlayer"; import { PipWindowController } from "./components/pip/PipWindowController"; import { AddToAlbumModal } from "./components/music/AddToAlbumModal"; @@ -262,6 +263,7 @@ function App() { + diff --git a/src/components/music/EqPanel.tsx b/src/components/music/EqPanel.tsx index 7f95955b..e80cca07 100644 --- a/src/components/music/EqPanel.tsx +++ b/src/components/music/EqPanel.tsx @@ -1,32 +1,20 @@ import { AnimatePresence, motion } from "framer-motion"; -import { X } from "lucide-react"; +import { SlidersHorizontal, X } from "lucide-react"; -import { useMusicPlayerStore } from "../../store/useMusicPlayerStore"; -import { - EQ_BANDS, - EQ_MAX_GAIN_DB, - EQ_PRESETS, - type EqPresetName, -} from "../../lib/audio/eqBands"; -import { ToggleSwitch } from "../ui/ToggleSwitch"; -import { Chip } from "../ui/Chip"; +import { EqLimits } from "../../lib/audio/eq/models"; +import { withBassBoost } from "../../lib/audio/eq/stateOps"; +import { pickEqPreset, restoreCustomEq, useEqualizer, useMusicEqAvailable } from "../../lib/audio/eq/useEqualizer"; import { getString } from "../../lib/i18n/index"; -import { Slider } from "../ui/Slider"; -import { useBoolPref } from "../../lib/usePreference"; import { SETTINGS } from "../../lib/settings/schema"; +import { useBoolPref } from "../../lib/usePreference"; +import { useEqStore } from "../../store/useEqStore"; +import { Chip } from "../ui/Chip"; +import { Slider } from "../ui/Slider"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { EqResponseGraph } from "./eq/EqResponseGraph"; +import { formatDb } from "./eq/eqFormat"; -const PRESETS: EqPresetName[] = ["flat", "bass", "vocal", "treble", "electronic", "acoustic"]; - -function arraysEqual(a: number[], b: number[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); -} - -function formatGain(db: number | undefined): string { - const v = Math.round(db ?? 0); - return v > 0 ? `+${v}` : `${v}`; -} - - +/** The quick equalizer panel: switch, curve, presets and bass boost, with Edit for the rest. */ export function EqPanel({ open, onClose, @@ -36,14 +24,14 @@ export function EqPanel({ onClose: () => void; placement?: "top" | "bottom"; }) { - const eqEnabled = useMusicPlayerStore((s) => s.eqEnabled); - const eqGains = useMusicPlayerStore((s) => s.eqGains); - const setEqEnabled = useMusicPlayerStore((s) => s.setEqEnabled); - const setEqBand = useMusicPlayerStore((s) => s.setEqBand); - const applyEqPreset = useMusicPlayerStore((s) => s.applyEqPreset); + const eq = useEqualizer(); + const available = useMusicEqAvailable(); + const update = useEqStore((s) => s.update); + const setEditorOpen = useEqStore((s) => s.setEditorOpen); const [normalization, setNormalization] = useBoolPref(SETTINGS.MUSIC_LOUDNESS_NORMALIZATION); const fromY = placement === "top" ? 10 : -10; + const selectedId = eq.active.presetId; return ( @@ -57,13 +45,15 @@ export function EqPanel({ style={{ transformOrigin: placement === "top" ? "bottom right" : "top right" }} className="w-80 rounded-2xl border border-chrome-neutral-800 bg-surface-container p-4" > - {/* header */} -
+
{getString("music_equalizer")}
- + update((s) => ({ ...s, enabled }))} + />
- {/* presets */} -
- {PRESETS.map((p) => ( - applyEqPreset(p)} - className="h-7 px-2.5 text-xs capitalize" - > - {p} - - ))} + {!available && ( +

+ {getString("eq_unavailable")} +

+ )} + +
+
+ +
+ +
+ {eq.hasCustom && ( + restoreCustomEq(eq.state.mode)} + className="h-7 px-2.5 text-xs" + > + {getString("eq_preset_custom")} + + )} + {eq.presets.map((preset) => ( + pickEqPreset(preset.id)} + className="h-7 px-2.5 text-xs" + > + {preset.name} + + ))} +
+ +
+ {getString("eq_bass_boost")} + update((s) => withBassBoost(s, db))} + aria-label={getString("eq_bass_boost")} + className="flex-1" + /> + + {formatDb(eq.state.bassBoost)} + +
- {/* sliders */} -
{ + onClose(); + setEditorOpen(true); + }} + className="mt-3 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-surface-container-high px-3 py-2 text-sm text-chrome-neutral-200 transition-colors duration-200 ease-out hover:bg-surface-container-highest" > - {EQ_BANDS.map((band, i) => ( -
- - {formatGain(eqGains[i])} - - setEqBand(i, gain)} - aria-label={`${band.label} Hz`} - /> - {band.label} -
- ))} -
+ + {getString("eq_edit")} +
diff --git a/src/components/music/GlobalMusicAudio.tsx b/src/components/music/GlobalMusicAudio.tsx index cd4d5edd..bfe31ab4 100644 --- a/src/components/music/GlobalMusicAudio.tsx +++ b/src/components/music/GlobalMusicAudio.tsx @@ -44,8 +44,6 @@ export function GlobalMusicAudio() { const s = useMusicPlayerStore.getState(); musicAudioEngine.setVolume(s.volume); musicAudioEngine.setMuted(s.isMuted); - musicAudioEngine.setEqEnabled(s.eqEnabled); - musicAudioEngine.setEqGains(s.eqGains); bindMusicAudioSettings(); }, []); diff --git a/src/components/music/GlobalMusicDock.tsx b/src/components/music/GlobalMusicDock.tsx index 0b913971..d450dff6 100644 --- a/src/components/music/GlobalMusicDock.tsx +++ b/src/components/music/GlobalMusicDock.tsx @@ -29,6 +29,7 @@ import { useNowPlayingMenuActions } from "./useNowPlayingMenuActions"; import { MusicArtwork } from "./MusicArtwork"; import { MusicScrubber } from "./MusicScrubber"; import { EqPanel } from "./EqPanel"; +import { useEqActive } from "../../lib/audio/eq/useEqualizer"; import { VolumePopover } from "./VolumePopover"; import { useDominantColor } from "../../lib/useDominantColor"; import { accentForeground, accentSurface } from "../../lib/accentColor"; @@ -44,7 +45,7 @@ export function GlobalMusicDock() { const isPlaying = useMusicPlayerStore((s) => s.isPlaying); const isBuffering = useMusicPlayerStore((s) => s.isBuffering); const loadingStreamId = useMusicPlayerStore((s) => s.loadingStreamId); - const eqEnabled = useMusicPlayerStore((s) => s.eqEnabled); + const eqEnabled = useEqActive(); const isShuffle = useMusicPlayerStore((s) => s.isShuffle); const repeatMode = useMusicPlayerStore((s) => s.repeatMode); const isMuted = useMusicPlayerStore((s) => s.isMuted); diff --git a/src/components/music/MusicOverlay.tsx b/src/components/music/MusicOverlay.tsx index 1a53d3bc..aa312143 100644 --- a/src/components/music/MusicOverlay.tsx +++ b/src/components/music/MusicOverlay.tsx @@ -38,6 +38,7 @@ import { MusicQueuePane } from "./MusicQueuePane"; import { MusicLyrics } from "./MusicLyrics"; import { AmbientBackdrop, isMusicBackgroundStyle } from "./AmbientBackdrop"; import { EqPanel } from "./EqPanel"; +import { useEqActive } from "../../lib/audio/eq/useEqualizer"; import { useLyrics } from "../../lib/lyrics/useLyrics"; import { useDominantColor } from "../../lib/useDominantColor"; import { accentForeground, accentSurface } from "../../lib/accentColor"; @@ -68,7 +69,7 @@ export function MusicOverlay() { const loadingStreamId = useMusicPlayerStore((s) => s.loadingStreamId); const repeatMode = useMusicPlayerStore((s) => s.repeatMode); const isShuffle = useMusicPlayerStore((s) => s.isShuffle); - const eqEnabled = useMusicPlayerStore((s) => s.eqEnabled); + const eqEnabled = useEqActive(); const togglePlay = useMusicPlayerStore((s) => s.togglePlay); const next = useMusicPlayerStore((s) => s.next); diff --git a/src/components/music/eq/EqBandList.tsx b/src/components/music/eq/EqBandList.tsx new file mode 100644 index 00000000..843a258c --- /dev/null +++ b/src/components/music/eq/EqBandList.tsx @@ -0,0 +1,121 @@ +import { Plus, Trash2 } from "lucide-react"; + +import { defaultQ, EQ_FILTER_TYPES, EqLimits, hasGain, type EqBand, type EqFilterType } from "../../../lib/audio/eq/models"; +import { getString, type StringKey } from "../../../lib/i18n/index"; +import { Select } from "../../ui/Select"; +import { Slider } from "../../ui/Slider"; +import { ToggleSwitch } from "../../ui/ToggleSwitch"; +import { EqNumberField } from "./EqNumberField"; + +const TYPE_LABELS: Record = { + PEAK: "eq_type_peak", + LOW_SHELF: "eq_type_low_shelf", + HIGH_SHELF: "eq_type_high_shelf", + LOW_PASS: "eq_type_low_pass", + HIGH_PASS: "eq_type_high_pass", +}; + +// Q spans two decades, so its slider runs on a log scale: the middle is Q 1.4, not Q 10. +const Q_STEPS = 1_000; +const qToSlider = (q: number) => + Math.round((Math.log(q / EqLimits.MIN_Q) / Math.log(EqLimits.MAX_Q / EqLimits.MIN_Q)) * Q_STEPS); +const sliderToQ = (v: number) => + Math.round(EqLimits.MIN_Q * Math.pow(EqLimits.MAX_Q / EqLimits.MIN_Q, v / Q_STEPS) * 100) / 100; + +export interface EqBandListProps { + bands: readonly EqBand[]; + selected: number | null; + canAdd: boolean; + onSelect: (index: number) => void; + onChange: (index: number, band: EqBand) => void; + onRemove: (index: number) => void; + onAdd: () => void; +} + +export function EqBandList({ bands, selected, canAdd, onSelect, onChange, onRemove, onAdd }: EqBandListProps) { + const typeOptions = EQ_FILTER_TYPES.map((type) => ({ value: type, label: getString(TYPE_LABELS[type]) })); + + return ( +
+ {bands.length === 0 ? ( +

+ {getString("eq_no_bands")} +

+ ) : ( +
    + {bands.map((band, index) => ( +
  • onSelect(index)} + onPointerDown={() => onSelect(index)} + className={`flex flex-wrap items-center gap-2 px-3 py-2 transition-colors duration-200 ease-out ${ + index === selected ? "bg-surface-container-high" : "hover:bg-surface-container-low" + }`} + > + {index + 1} +