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
4 changes: 4 additions & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions src-tauri/src/commands/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)));
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/sync/settings_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions src-tauri/tests/sync_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")])
Expand Down Expand Up @@ -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"
);
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -262,6 +263,7 @@ function App() {
<GlobalMusicDock />
<MusicOverlay />
</LayoutGroup>
<EqualizerDialog />
<AddToAlbumModal />
<AddTracksToAlbumModal />
<AddToPlaylistModal />
Expand Down
147 changes: 80 additions & 67 deletions src/components/music/EqPanel.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<AnimatePresence>
Expand All @@ -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 */}
<div className="mb-4 flex items-center justify-between">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-widest text-chrome-neutral-500">
{getString("music_equalizer")}
</span>
<div className="flex items-center gap-2">
<ToggleSwitch checked={eqEnabled} onChange={setEqEnabled} />
<ToggleSwitch
checked={eq.state.enabled}
onChange={(enabled) => update((s) => ({ ...s, enabled }))}
/>
<button
type="button"
onClick={onClose}
Expand All @@ -75,44 +65,67 @@ export function EqPanel({
</div>
</div>

{/* presets */}
<div className="mb-4 flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<Chip
key={p}
active={arraysEqual(eqGains, EQ_PRESETS[p])}
onClick={() => applyEqPreset(p)}
className="h-7 px-2.5 text-xs capitalize"
>
{p}
</Chip>
))}
{!available && (
<p className="mb-3 rounded-lg border border-chrome-neutral-800 px-3 py-2 text-xs text-chrome-neutral-400">
{getString("eq_unavailable")}
</p>
)}

<div className={`flex flex-col gap-3 transition-opacity duration-200 ${eq.state.enabled ? "" : "opacity-40"}`}>
<div className="rounded-lg border border-chrome-neutral-800 bg-surface-container-low">
<EqResponseGraph bands={eq.drawnBands} height={64} rangeDb={15} ariaLabel={getString("eq_graph_label")} />
</div>

<div className="flex max-h-24 flex-wrap gap-1.5 overflow-y-auto hide-scrollbar">
{eq.hasCustom && (
<Chip
active={selectedId === null}
onClick={() => restoreCustomEq(eq.state.mode)}
className="h-7 px-2.5 text-xs"
>
{getString("eq_preset_custom")}
</Chip>
)}
{eq.presets.map((preset) => (
<Chip
key={preset.id}
active={preset.id === selectedId}
onClick={() => pickEqPreset(preset.id)}
className="h-7 px-2.5 text-xs"
>
{preset.name}
</Chip>
))}
</div>

<div className="flex items-center gap-3">
<span className="text-sm text-chrome-neutral-200">{getString("eq_bass_boost")}</span>
<Slider
min={0}
max={EqLimits.MAX_BASS_BOOST}
step={0.5}
value={eq.state.bassBoost}
onChange={(db) => update((s) => withBassBoost(s, db))}
aria-label={getString("eq_bass_boost")}
className="flex-1"
/>
<span className="w-10 text-right font-mono text-xs text-chrome-neutral-400">
{formatDb(eq.state.bassBoost)}
</span>
</div>
</div>

{/* sliders */}
<div
className={`flex items-end justify-between gap-1 transition-opacity duration-200 ${
eqEnabled ? "" : "pointer-events-none opacity-40"
}`}
<button
type="button"
onClick={() => {
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) => (
<div key={band.frequency} className="flex flex-col items-center gap-1.5">
<span className="font-mono text-[10px] tabular-nums text-chrome-neutral-500">
{formatGain(eqGains[i])}
</span>
<Slider
orientation="vertical"
min={-EQ_MAX_GAIN_DB}
max={EQ_MAX_GAIN_DB}
step={1}
value={eqGains[i] ?? 0}
onChange={(gain) => setEqBand(i, gain)}
aria-label={`${band.label} Hz`}
/>
<span className="text-[10px] text-chrome-neutral-500">{band.label}</span>
</div>
))}
</div>
<SlidersHorizontal className="h-4 w-4" />
{getString("eq_edit")}
</button>

<div className="mt-4 flex items-center justify-between gap-3 border-t border-chrome-neutral-800 pt-3">
<span className="flex min-w-0 flex-col">
Expand Down
2 changes: 0 additions & 2 deletions src/components/music/GlobalMusicAudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}, []);

Expand Down
3 changes: 2 additions & 1 deletion src/components/music/GlobalMusicDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/components/music/MusicOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading