diff --git a/jest/contextual/EqualizerScreen.test.tsx b/jest/contextual/EqualizerScreen.test.tsx new file mode 100644 index 000000000..0fc87742e --- /dev/null +++ b/jest/contextual/EqualizerScreen.test.tsx @@ -0,0 +1,213 @@ +import React from 'react' +import { StyleSheet } from 'react-native' +import { fireEvent, render } from '@testing-library/react-native' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import { TamaguiProvider, Theme } from 'tamagui' +import { + useEqualizer, + useEqualizerPresets, + type EqualizerBand, + type EqualizerPreset, +} from 'react-native-nitro-player' + +import EqualizerScreen from '../../src/screens/Settings/equalizer' +import { useIsCasting } from '../../src/stores/player/engine' +import config from '../../src/configs/tamagui.config' + +jest.mock('../../src/stores/player/engine', () => ({ + useIsCasting: jest.fn().mockReturnValue(false), +})) + +const FREQUENCIES = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] + +const BUILT_IN_PRESET_NAMES = [ + 'Flat', + 'Rock', + 'Pop', + 'Classical', + 'Dance', + 'Techno', + 'Club', + 'Live', + 'Reggae', + 'Full Bass', + 'Full Treble', + 'Full Bass & Treble', + 'Large Hall', + 'Party', + 'Ska', + 'Soft', + 'Soft Rock', + 'Headphones', + 'Laptop Speakers', +] + +function buildBands(gains: Partial> = {}): EqualizerBand[] { + return FREQUENCIES.map((centerFrequency, index) => ({ + index, + centerFrequency, + gainDb: gains[index] ?? 0, + frequencyLabel: + centerFrequency >= 1000 ? `${centerFrequency / 1000} kHz` : `${centerFrequency} Hz`, + })) +} + +function buildPresets(): EqualizerPreset[] { + return BUILT_IN_PRESET_NAMES.map((name) => ({ + name, + gains: new Array(10).fill(0), + type: 'built-in', + })) +} + +function mockEqualizer(overrides: Partial> = {}) { + const value = { + isEnabled: true, + bands: buildBands(), + currentPreset: 'Flat', + setEnabled: jest.fn().mockResolvedValue(true), + setBandGain: jest.fn().mockResolvedValue(true), + setAllBandGains: jest.fn().mockResolvedValue(true), + reset: jest.fn().mockResolvedValue(undefined), + isLoading: false, + gainRange: { min: -12, max: 12 }, + ...overrides, + } + + jest.mocked(useEqualizer).mockReturnValue(value) + + return value +} + +function mockPresets(overrides: Partial> = {}) { + const value = { + presets: buildPresets(), + builtInPresets: buildPresets(), + customPresets: [], + applyPreset: jest.fn().mockResolvedValue(true), + saveCustomPreset: jest.fn().mockResolvedValue(true), + deleteCustomPreset: jest.fn().mockResolvedValue(true), + currentPreset: 'Flat' as string | null, + isLoading: false, + refreshPresets: jest.fn(), + } + + jest.mocked(useEqualizerPresets).mockReturnValue({ ...value, ...overrides }) + + return value +} + +function renderScreen() { + return render( + + + + + + + + + , + ) +} + +describe('EqualizerScreen', () => { + beforeEach(() => { + jest.mocked(useIsCasting).mockReturnValue(false) + mockEqualizer() + mockPresets() + }) + + it('renders the enable toggle and ten band rows', () => { + const { getByTestId, getAllByTestId, getByText } = renderScreen() + + expect(getByTestId('equalizer-enabled-switch')).toBeTruthy() + expect(getAllByTestId(/equalizer-band-row-/)).toHaveLength(10) + expect(getByText('31 Hz')).toBeTruthy() + expect(getByText('16 kHz')).toBeTruthy() + }) + + it('renders formatted gain readouts', () => { + mockEqualizer({ bands: buildBands({ 5: 3, 0: -2.5 }) }) + + const { getByText, getAllByText } = renderScreen() + + expect(getByText('+3.0 dB')).toBeTruthy() + expect(getByText('-2.5 dB')).toBeTruthy() + expect(getAllByText('0 dB')).toHaveLength(8) + }) + + it('toggling the switch calls setEnabled', () => { + const equalizer = mockEqualizer({ isEnabled: false }) + + const { getByTestId } = renderScreen() + + fireEvent(getByTestId('equalizer-enabled-switch'), 'checkedChange', true) + + expect(equalizer.setEnabled).toHaveBeenCalledWith(true) + }) + + it('renders a chip per built-in preset and applies on press', () => { + const presets = mockPresets() + + const { getAllByTestId, getByText } = renderScreen() + + expect(getAllByTestId(/equalizer-preset-/)).toHaveLength(BUILT_IN_PRESET_NAMES.length) + + fireEvent.press(getByText('Rock')) + + expect(presets.applyPreset).toHaveBeenCalledWith('Rock') + }) + + it('shows Custom when no preset is active', () => { + mockEqualizer({ currentPreset: null }) + mockPresets({ currentPreset: null }) + + const { getByText } = renderScreen() + + expect(getByText('Current: Custom')).toBeTruthy() + }) + + it('reset button calls reset', () => { + const equalizer = mockEqualizer() + + const { getByTestId } = renderScreen() + + fireEvent.press(getByTestId('equalizer-reset-button')) + + expect(equalizer.reset).toHaveBeenCalled() + }) + + it('shows the cast hint only while casting', () => { + const { queryByTestId, unmount } = renderScreen() + + expect(queryByTestId('equalizer-cast-hint')).toBeNull() + + unmount() + + jest.mocked(useIsCasting).mockReturnValue(true) + + const { getByTestId } = renderScreen() + + expect(getByTestId('equalizer-cast-hint')).toBeTruthy() + }) + + it('dims and blocks the sections when disabled', () => { + mockEqualizer({ isEnabled: false }) + + const { getByTestId } = renderScreen() + + const sections = getByTestId('equalizer-sections') + + expect(sections).toHaveStyle({ opacity: 0.5 }) + + const flattened = StyleSheet.flatten(sections.props.style) + expect(sections.props.pointerEvents ?? flattened?.pointerEvents).toBe('none') + }) +}) diff --git a/jest/functional/Equalizer.test.ts b/jest/functional/Equalizer.test.ts new file mode 100644 index 000000000..d83c59514 --- /dev/null +++ b/jest/functional/Equalizer.test.ts @@ -0,0 +1,60 @@ +import { + EQ_GAIN_MAX, + EQ_GAIN_MIN, + EQ_SLIDER_MAX, + dbToSliderValue, + formatGainLabel, + sliderValueToDb, +} from '../../src/utils/audio/equalizer' + +describe('Equalizer Mapping Module', () => { + it('maps slider extremes and midpoint to dB', () => { + expect(sliderValueToDb(0)).toBe(EQ_GAIN_MIN) + expect(sliderValueToDb(EQ_SLIDER_MAX)).toBe(EQ_GAIN_MAX) + expect(sliderValueToDb(EQ_SLIDER_MAX / 2)).toBe(0) + }) + + it('rounds slider values to 0.5 dB steps', () => { + expect(sliderValueToDb(13.3)).toBe(1.5) + expect(sliderValueToDb(13.2)).toBe(1) + expect(sliderValueToDb(11.75)).toBe(0) + }) + + it('clamps out-of-range slider values', () => { + expect(sliderValueToDb(-1)).toBe(EQ_GAIN_MIN) + expect(sliderValueToDb(EQ_SLIDER_MAX + 1)).toBe(EQ_GAIN_MAX) + }) + + it('maps dB to slider position without rounding', () => { + expect(dbToSliderValue(EQ_GAIN_MIN)).toBe(0) + expect(dbToSliderValue(0)).toBe(EQ_SLIDER_MAX / 2) + + // Built-in presets use fractional gains, e.g. Rock boosts 31 Hz by 4.8 dB + expect(dbToSliderValue(4.8)).toBeCloseTo(16.8, 10) + }) + + it('clamps out-of-range gains', () => { + expect(dbToSliderValue(EQ_GAIN_MIN - 1)).toBe(0) + expect(dbToSliderValue(EQ_GAIN_MAX + 1)).toBe(EQ_SLIDER_MAX) + }) + + it('round-trips step-aligned gains', () => { + for (const gainDb of [-12, -2.5, 0, 0.5, 12]) { + expect(sliderValueToDb(dbToSliderValue(gainDb))).toBe(gainDb) + } + }) + + it('formats gain labels', () => { + expect(formatGainLabel(3)).toBe('+3.0 dB') + expect(formatGainLabel(-2.5)).toBe('-2.5 dB') + expect(formatGainLabel(12)).toBe('+12.0 dB') + expect(formatGainLabel(0)).toBe('0 dB') + }) + + it('treats negative zero and float dust as flat', () => { + expect(formatGainLabel(-0)).toBe('0 dB') + expect(formatGainLabel(0.04)).toBe('0 dB') + expect(sliderValueToDb(12.1)).toBe(0) + expect(Object.is(sliderValueToDb(11.9), 0)).toBe(true) + }) +}) diff --git a/jest/setup/nitro-player.ts b/jest/setup/nitro-player.ts index a7159504e..a88c19bde 100644 --- a/jest/setup/nitro-player.ts +++ b/jest/setup/nitro-player.ts @@ -28,6 +28,26 @@ jest.mock('react-native-nitro-player', () => ({ removeTrackFromPlaylist: jest.fn(), reorderTrackInPlaylist: jest.fn(), }, + Equalizer: { + setEnabled: jest.fn().mockResolvedValue(undefined), + isEnabled: jest.fn().mockReturnValue(false), + getBands: jest.fn().mockResolvedValue([]), + setBandGain: jest.fn().mockResolvedValue(undefined), + setAllBandGains: jest.fn().mockResolvedValue(undefined), + getBandRange: jest.fn().mockReturnValue({ min: -12, max: 12 }), + getPresets: jest.fn().mockReturnValue([]), + getBuiltInPresets: jest.fn().mockReturnValue([]), + getCustomPresets: jest.fn().mockReturnValue([]), + applyPreset: jest.fn().mockResolvedValue(undefined), + getCurrentPresetName: jest.fn().mockReturnValue(null), + saveCustomPreset: jest.fn().mockResolvedValue(undefined), + deleteCustomPreset: jest.fn().mockResolvedValue(undefined), + getState: jest.fn().mockResolvedValue({ enabled: false, bands: [], currentPreset: null }), + reset: jest.fn().mockResolvedValue(undefined), + onEnabledChange: jest.fn(), + onBandChange: jest.fn(), + onPresetChange: jest.fn(), + }, DownloadManager: { configure: jest.fn(), downloadTrack: jest.fn(), @@ -57,4 +77,36 @@ jest.mock('react-native-nitro-player', () => ({ useOnPlaybackStateChange: jest.fn().mockReturnValue({ state: 'none' }), useDownloadProgress: jest.fn().mockReturnValue({ progress: 0 }), useDownloadedTracks: jest.fn().mockReturnValue({ downloadedTracks: [] }), + useEqualizer: jest.fn().mockReturnValue({ + isEnabled: false, + bands: [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map( + (centerFrequency, index) => ({ + index, + centerFrequency, + gainDb: 0, + frequencyLabel: + centerFrequency >= 1000 + ? `${centerFrequency / 1000} kHz` + : `${centerFrequency} Hz`, + }), + ), + currentPreset: null, + setEnabled: jest.fn().mockResolvedValue(true), + setBandGain: jest.fn().mockResolvedValue(true), + setAllBandGains: jest.fn().mockResolvedValue(true), + reset: jest.fn().mockResolvedValue(undefined), + isLoading: false, + gainRange: { min: -12, max: 12 }, + }), + useEqualizerPresets: jest.fn().mockReturnValue({ + presets: [], + builtInPresets: [], + customPresets: [], + applyPreset: jest.fn().mockResolvedValue(true), + saveCustomPreset: jest.fn().mockResolvedValue(true), + deleteCustomPreset: jest.fn().mockResolvedValue(true), + currentPreset: null, + isLoading: false, + refreshPresets: jest.fn(), + }), })) diff --git a/src/components/Settings/components/sections/action-chip.tsx b/src/components/Settings/components/sections/action-chip.tsx index e4a30144a..8309b9a69 100644 --- a/src/components/Settings/components/sections/action-chip.tsx +++ b/src/components/Settings/components/sections/action-chip.tsx @@ -6,7 +6,7 @@ import Icon from '../../../Global/components/icon' interface ActionChipProps { active: boolean label: string - icon: string + icon?: string onPress: () => void testID?: string } @@ -29,7 +29,11 @@ export default function ActionChip({ paddingHorizontal='$2.5' size='$2' borderRadius='$10' - icon={} + icon={ + icon ? ( + + ) : undefined + } > {label} diff --git a/src/components/Settings/components/vertical-settings.tsx b/src/components/Settings/components/vertical-settings.tsx index 1d0f9d51b..592655e09 100644 --- a/src/components/Settings/components/vertical-settings.tsx +++ b/src/components/Settings/components/vertical-settings.tsx @@ -94,6 +94,13 @@ export default function VerticalSettings(): React.JSX.Element { route='Playback' iconColor='$warning' /> + { + if (!(await setEnabled(enabled))) + captureWarning(LoggingContext.Equalizer, 'Failed to toggle equalizer') + } + + const handleGainChange = async (bandIndex: number, gainDb: number) => { + if (!(await setBandGain(bandIndex, gainDb))) + captureWarning(LoggingContext.Equalizer, `Failed to set gain for band ${bandIndex}`) + } + + const handleApplyPreset = async (name: string) => { + if (!(await applyPreset(name))) + captureWarning(LoggingContext.Equalizer, `Failed to apply preset ${name}`) + } + + return ( + + + + + + + Enable Equalizer + + + Adjust the gain of individual frequency bands + + + + + + {isCasting && ( + + + + The equalizer doesn't affect Google Cast playback + + + )} + + + + + + Presets + + + {`Current: ${currentPreset ?? 'Custom'}`} + + + + + {builtInPresets.map((preset) => ( + handleApplyPreset(preset.name)} + /> + ))} + + + + + + + Bands + + + + + + {bands.map((band) => ( + + ))} + + + + + + ) +} + +interface EqualizerBandRowProps { + band: EqualizerBand + onGainChange: (bandIndex: number, gainDb: number) => void +} + +function EqualizerBandRow({ band, onGainChange }: EqualizerBandRowProps): React.JSX.Element { + const { primary, neutral } = useTheme() + + const gestureActive = useRef(false) + + const sliderValue = useSharedValue(dbToSliderValue(band.gainDb)) + + const [displayGainDb, setDisplayGainDb] = useState(band.gainDb) + + /** + * Only update the readout from the slider while the user is dragging; + * external changes (presets, reset, native events) are synced below with + * their exact gains, which may sit between the slider's committed steps. + */ + const handleDisplayGainChange = (value: number) => { + if (gestureActive.current) setDisplayGainDb(value + EQ_GAIN_MIN) + } + + useAnimatedReaction( + // 0.5 dB steps inline, as imported helpers aren't auto-workletized + () => Math.round(sliderValue.value * 2) / 2, + (current, previous) => { + if (current !== previous) runOnJS(handleDisplayGainChange)(current) + }, + ) + + /** + * Sync the slider when the band changes externally. The gesture ref guard + * keeps an in-flight drag from being clobbered; a commit can't loop back + * through here because the slider only fires onValueChange from gestures + * and the committed gain is already rounded. + */ + useEffect(() => { + if (!gestureActive.current) { + sliderValue.value = dbToSliderValue(band.gainDb) + setDisplayGainDb(band.gainDb) + } + }, [band.gainDb]) + + const handleValueChange = (value: number) => { + const gainDb = sliderValueToDb(value) + + // Snap the thumb to the committed step + sliderValue.value = dbToSliderValue(gainDb) + + onGainChange(band.index, gainDb) + } + + return ( + + + {band.frequencyLabel} + + + + + + + + {formatGainLabel(displayGainDb)} + + + ) +} diff --git a/src/screens/Settings/index.ts b/src/screens/Settings/index.ts index 075b37139..c54102c71 100644 --- a/src/screens/Settings/index.ts +++ b/src/screens/Settings/index.ts @@ -10,6 +10,7 @@ import AccountScreen from './account' import AboutScreen from './about' import PrivacyDeveloperScreen from './privacy-developer' import PlaybackScreen from './playback' +import EqualizerScreen from './equalizer' import GesturesScreen from './gestures' import AppearanceScreen from './appearance' @@ -105,6 +106,17 @@ const SettingsStack = createNativeStackNavigator({ }, }, }, + Equalizer: { + screen: EqualizerScreen, + options: { + title: 'Equalizer', + headerShown: true, + headerTitleAlign: 'center', + headerTitleStyle: { + fontFamily: 'Figtree-Bold', + }, + }, + }, PrivacyDeveloper: { screen: PrivacyDeveloperScreen, options: { diff --git a/src/screens/Settings/types.ts b/src/screens/Settings/types.ts index 1da1ebe7f..258e69187 100644 --- a/src/screens/Settings/types.ts +++ b/src/screens/Settings/types.ts @@ -11,6 +11,7 @@ export type SettingsStackParamList = { Appearance: undefined Gestures: undefined Playback: undefined + Equalizer: undefined PrivacyDeveloper: undefined About: undefined } diff --git a/src/utils/audio/equalizer.ts b/src/utils/audio/equalizer.ts new file mode 100644 index 000000000..701578b5b --- /dev/null +++ b/src/utils/audio/equalizer.ts @@ -0,0 +1,72 @@ +/** + * The minimum gain in decibels that can be applied to an equalizer band. + */ +export const EQ_GAIN_MIN = -12 + +/** + * The maximum gain in decibels that can be applied to an equalizer band. + */ +export const EQ_GAIN_MAX = 12 + +/** + * The maximum value of a band slider. + * + * Band sliders run from zero to this value since the slider component + * doesn't support negative values, so slider values are shifted by + * {@link EQ_GAIN_MIN} to get the gain in decibels. + */ +export const EQ_SLIDER_MAX = EQ_GAIN_MAX - EQ_GAIN_MIN + +/** + * The step in decibels that band gains are rounded to when committed. + */ +export const EQ_GAIN_STEP = 0.5 + +/** + * Converts a band slider value to a gain in decibels. + * + * The value is clamped to the slider range, shifted into the gain range, + * and rounded to the nearest {@link EQ_GAIN_STEP}. + * + * @param value - The slider value to convert. + * @returns The gain in decibels. + */ +export function sliderValueToDb(value: number): number { + const clamped = Math.min(EQ_SLIDER_MAX, Math.max(0, value)) + + const stepped = Math.round((clamped + EQ_GAIN_MIN) / EQ_GAIN_STEP) * EQ_GAIN_STEP + + // Normalize negative zero so labels and equality checks behave + return stepped === 0 ? 0 : stepped +} + +/** + * Converts a gain in decibels to a band slider value. + * + * The gain is clamped to the gain range but deliberately not rounded, + * so the fractional gains used by built-in presets position the slider + * thumb accurately. + * + * @param gainDb - The gain in decibels to convert. + * @returns The slider value. + */ +export function dbToSliderValue(gainDb: number): number { + const clamped = Math.min(EQ_GAIN_MAX, Math.max(EQ_GAIN_MIN, gainDb)) + + return clamped - EQ_GAIN_MIN +} + +/** + * Formats a band gain as a user-facing label, e.g. "+3.0 dB", "-2.5 dB" or "0 dB". + * + * Gains within a twentieth of a decibel of flat are treated as zero so that + * negative zero and float dust from native preset gains don't render as "-0.0 dB". + * + * @param gainDb - The gain in decibels to format. + * @returns The formatted gain label. + */ +export function formatGainLabel(gainDb: number): string { + if (Math.abs(gainDb) < 0.05) return '0 dB' + + return `${gainDb > 0 ? '+' : '-'}${Math.abs(gainDb).toFixed(1)} dB` +} diff --git a/src/utils/logging/enums.ts b/src/utils/logging/enums.ts index f2c4e6195..98eee7a46 100644 --- a/src/utils/logging/enums.ts +++ b/src/utils/logging/enums.ts @@ -26,6 +26,7 @@ enum LoggingContext { UI = 'UI', MediaInfo = 'MediaInfo', NitroPlayer = 'Nitro Player', + Equalizer = 'Equalizer', } export default LoggingContext