diff --git a/Cargo.lock b/Cargo.lock index b76a79a1..42327d47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1821,9 +1821,9 @@ dependencies = [ [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "event-listener" diff --git a/apps/web-mobile/src/atoms/equalizer.ts b/apps/web-mobile/src/atoms/equalizer.ts index cbd412de..edddc9ae 100644 --- a/apps/web-mobile/src/atoms/equalizer.ts +++ b/apps/web-mobile/src/atoms/equalizer.ts @@ -20,16 +20,35 @@ export const EQ_BANDS: EqBandSetting[] = EQ_BANDS_HZ.map((cutoff) => ({ // Q=7.0 matches the Rockbox firmware default. export const EQ_Q = 7.0; -export const eqEnabledAtom = atomWithStorage("eq_enabled", false); -export const eqBandsAtom = atomWithStorage("eq_bands", EQ_BANDS); +// getOnInit reads localStorage synchronously so the first render already has +// the stored EQ, not the flat default — otherwise the engine gets a flat EQ +// pushed for one render on app load (audible flicker). +const SYNC_INIT = { getOnInit: true } as const; + +export const eqEnabledAtom = atomWithStorage( + "eq_enabled", + false, + undefined, + SYNC_INIT, +); +export const eqBandsAtom = atomWithStorage( + "eq_bands", + EQ_BANDS, + undefined, + SYNC_INIT, +); // ── Crossfade (rockbox pcmbuf algorithm, run in the wasm engine) ───────────── export const crossfadeEnabledAtom = atomWithStorage( "crossfade_enabled", false, + undefined, + SYNC_INIT, ); // Fade in/out duration in seconds (Rockbox range 0–15 s). export const crossfadeDurationAtom = atomWithStorage( "crossfade_duration", 2, + undefined, + SYNC_INIT, ); diff --git a/apps/web/src/components/StickyPlayer/StickyPlayerWithData.tsx b/apps/web/src/components/StickyPlayer/StickyPlayerWithData.tsx index 60a2f7b9..5b027026 100644 --- a/apps/web/src/components/StickyPlayer/StickyPlayerWithData.tsx +++ b/apps/web/src/components/StickyPlayer/StickyPlayerWithData.tsx @@ -31,6 +31,7 @@ import { import { ensureStreamToken } from "../../api/uploads"; import { SILENT_AUDIO_DATA_URI } from "../../lib/audio/silence"; import { useRockboxEngine } from "../../hooks/useRockboxEngine"; +import { useAudioSettingsPublisher } from "../../hooks/useAudioSettings"; import { useUploadResume } from "../../hooks/useUploadResume"; import { useUploadScrobble } from "../../hooks/useUploadScrobble"; @@ -98,6 +99,9 @@ function StickyPlayerWithData() { // Persist the upload queue + position to localStorage and rehydrate it on // reload (the engine is reloaded lazily on the next play — see onPlay). useUploadResume(); + // Keep the engine's DSP chain (EQ/tone/crossfade/…) in sync with the saved + // audio settings from app load, so opening Audio Settings applies nothing new. + useAudioSettingsPublisher(); const queryClient = useQueryClient(); const feedUri = useAtomValue(feedGeneratorUriAtom); const [liked, setLiked] = useState>({}); diff --git a/apps/web/src/hooks/useAudioSettings.tsx b/apps/web/src/hooks/useAudioSettings.tsx index 91c0bd96..6a1d8664 100644 --- a/apps/web/src/hooks/useAudioSettings.tsx +++ b/apps/web/src/hooks/useAudioSettings.tsx @@ -101,10 +101,29 @@ function bandRockboxToLex(b: EqBand): LexEqBand { // Persisted to localStorage so the DSP chain survives reloads without a network // round-trip. Seeded from firmware defaults; hydrated from the lexicon on mount. -const settingsAtom = atomWithStorage( +// Exported so an always-mounted publisher (useAudioSettingsPublisher) can push +// it to the engine on app load — without it, the settings only reach the engine +// when the Audio Settings page/EQ modal mounts, and the flat default gets +// applied for a render first (audible EQ flicker on first open). +// getOnInit reads localStorage synchronously so the very first render already +// has the stored settings, not the flat default. +export const audioSettingsAtom = atomWithStorage( "rocksky:audio.settings", DEFAULT_GLOBAL_SETTINGS, + undefined, + { getOnInit: true }, ); +const settingsAtom = audioSettingsAtom; + +/** Always-mounted: keep the engine's DSP chain in sync with the persisted audio + * settings from app load on, so opening Audio Settings later applies nothing + * new (publishAudioSettings is idempotent) and there's no audible flicker. */ +export function useAudioSettingsPublisher(): void { + const settings = useAtomValue(audioSettingsAtom); + useEffect(() => { + publishAudioSettings(settings); + }, [settings]); +} /** Merge a lexicon record into a GlobalSettings snapshot. Anything the lexicon * doesn't specify keeps its current value. */ @@ -229,11 +248,9 @@ export function useAudioSettings(): { staleTime: 60_000, }); - // Push the current snapshot to the wasm engine whenever it changes (and on - // mount). Cheap + idempotent; the engine adopts it live or on next init(). - useEffect(() => { - publishAudioSettings(settings); - }, [settings]); + // Engine sync lives in the always-mounted useAudioSettingsPublisher (mounted + // by the sticky player) so the DSP chain is in sync from app load — not only + // while this settings-page hook is mounted. // Hydrate the local snapshot from the lexicon ONCE per did. useEffect(() => { diff --git a/apps/web/src/lib/audio/rockbox-engine.ts b/apps/web/src/lib/audio/rockbox-engine.ts index 0dce05cd..0f5df4f4 100644 --- a/apps/web/src/lib/audio/rockbox-engine.ts +++ b/apps/web/src/lib/audio/rockbox-engine.ts @@ -21,6 +21,8 @@ let player: RockboxPlayer | null = null; * late-booting engine (init happens on a user gesture, which can land after the * settings have already loaded) can adopt the user's settings on `init()`. */ let latestSettings: GlobalSettings | null = null; +/** JSON of the last snapshot actually applied — used to skip redundant re-applies. */ +let latestSettingsJson = ""; // Transport modes are likewise remembered so they survive a late init — the // repeat/shuffle effects can run before the engine has booted (e.g. repeat set @@ -79,7 +81,10 @@ export async function ensureRockboxReady(): Promise { // Apply whatever DSP settings were last published (the engine may have // booted after the settings loaded), so a fresh AudioContext starts with // the user's EQ/crossfade/etc. rather than firmware defaults. - if (latestSettings) applyAudioSettings(p, latestSettings); + if (latestSettings) { + applyAudioSettings(p, latestSettings); + latestSettingsJson = JSON.stringify(latestSettings); + } p.setRepeat(latestRepeat); p.setShuffle(latestShuffle); } @@ -264,8 +269,16 @@ export function applyAudioSettings( /** Publish the current DSP snapshot: remember it (so a late `init()` adopts it) * and, if the engine is already running, push it live. Safe to call before the - * engine has booted — it'll be applied on the next `ensureRockboxReady()`. */ + * engine has booted — it'll be applied on the next `ensureRockboxReady()`. + * + * Idempotent: if the snapshot is byte-for-byte identical to the last one we + * applied, we skip re-applying it. Re-pushing the same EQ recomputes the DSP's + * IIR coefficients and briefly disturbs the audio, which is what caused the + * "EQ flickers when I open Audio Settings" glitch. */ export function publishAudioSettings(s: GlobalSettings): void { latestSettings = s; + const json = JSON.stringify(s); + if (json === latestSettingsJson) return; + latestSettingsJson = json; if (player?.ready) applyAudioSettings(player, s); }