diff --git a/package-lock.json b/package-lock.json index 78bc78a8c..a93ab5548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "aqua", + "name": "AQUA", "lockfileVersion": 2, "requires": true, "packages": { @@ -14,7 +14,8 @@ "react": "^18.1.0", "react-dom": "^18.1.0", "react-router-dom": "^6.3.0", - "regedit": "^5.1.1" + "regedit": "^5.1.1", + "uid": "^2.0.0" }, "devDependencies": { "@pmmmwh/react-refresh-webpack-plugin": "^0.5.6", @@ -1406,6 +1407,14 @@ "integrity": "sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg==", "dev": true }, + "node_modules/@lukeed/csprng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.0.1.tgz", + "integrity": "sha512-uSvJdwQU5nK+Vdf6zxcWAY2A8r7uqe+gePwLWzJ+fsQehq18pc0I2hJKwypZ2aLM90+Er9u1xn4iLJPZ+xlL4g==", + "engines": { + "node": ">=8" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", @@ -21936,6 +21945,17 @@ "node": "*" } }, + "node_modules/uid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.0.tgz", + "integrity": "sha512-hFw+zKBA1szYdbZWj6FjTxZzJnKNf+wTDcsxlJaXS64MCy9LQEmJUVieGYHCKek/WRyFIcs0cEXtGIQmfvHe2A==", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/unbox-primitive": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", @@ -24487,6 +24507,11 @@ "integrity": "sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg==", "dev": true }, + "@lukeed/csprng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.0.1.tgz", + "integrity": "sha512-uSvJdwQU5nK+Vdf6zxcWAY2A8r7uqe+gePwLWzJ+fsQehq18pc0I2hJKwypZ2aLM90+Er9u1xn4iLJPZ+xlL4g==" + }, "@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", @@ -40336,6 +40361,14 @@ "integrity": "sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==", "dev": true }, + "uid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.0.tgz", + "integrity": "sha512-hFw+zKBA1szYdbZWj6FjTxZzJnKNf+wTDcsxlJaXS64MCy9LQEmJUVieGYHCKek/WRyFIcs0cEXtGIQmfvHe2A==", + "requires": { + "@lukeed/csprng": "^1.0.0" + } + }, "unbox-primitive": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", diff --git a/package.json b/package.json index 643901ba2..cc994c274 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,8 @@ "react": "^18.1.0", "react-dom": "^18.1.0", "react-router-dom": "^6.3.0", - "regedit": "^5.1.1" + "regedit": "^5.1.1", + "uid": "^2.0.0" }, "devDependencies": { "@pmmmwh/react-refresh-webpack-plugin": "^0.5.6", @@ -269,4 +270,4 @@ ], "logLevel": "quiet" } -} \ No newline at end of file +} diff --git a/release/app/package.json b/release/app/package.json index ba13e0435..c7be08d58 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,6 +1,6 @@ { "name": "aqua", - "version": "0.0.8", + "version": "1.0.0", "description": "An audio equalizer app", "license": "MIT", "author": { diff --git a/src/__tests__/unit_tests/common/utils.test.ts b/src/__tests__/unit_tests/common/utils.test.ts index f8040ae0c..dcda4682a 100644 --- a/src/__tests__/unit_tests/common/utils.test.ts +++ b/src/__tests__/unit_tests/common/utils.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_STATE } from 'common/constants'; +import { getDefaultState } from 'common/constants'; import { computeAvgFreq, roundToPrecision } from 'common/utils'; describe('utils', () => { @@ -16,7 +16,7 @@ describe('utils', () => { }); describe('computeAvgFreq', () => { - const filters = [...DEFAULT_STATE.filters]; + const filters = [...getDefaultState().filters]; it('should compute average of first filter and min frequency for index 0', () => { expect(computeAvgFreq(filters, 0)).toBe(6); }); diff --git a/src/__tests__/utils/mockAquaProvider.ts b/src/__tests__/utils/mockAquaProvider.ts index d2bc13ca0..29caf75a3 100644 --- a/src/__tests__/utils/mockAquaProvider.ts +++ b/src/__tests__/utils/mockAquaProvider.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { DEFAULT_STATE } from 'common/constants'; +import { getDefaultState } from 'common/constants'; import { ErrorDescription } from 'common/errors'; import { FilterAction, IAquaContext } from 'renderer/utils/AquaContext'; +const DEFAULT_STATE = getDefaultState(); + const defaultAquaContext: IAquaContext = { isLoading: false, globalError: undefined, diff --git a/src/common/constants.ts b/src/common/constants.ts index 8fccfcb65..f0e422bff 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,5 +1,7 @@ /** ----- Application Constants ----- */ +import { uid } from 'uid'; + export const MAX_GAIN = 30; export const MIN_GAIN = -30; @@ -46,6 +48,7 @@ export const WINDOW_HEIGHT_EXPANDED = 1036; /** ----- Application Interfaces ----- */ export interface IFilter { + id: string; frequency: number; gain: number; type: FilterTypeEnum; @@ -66,26 +69,31 @@ const FIXED_FREQUENCIES = [ 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000, ]; -export const DEFAULT_FILTER: IFilter = { +const DEFAULT_FILTER_TEMPLATE = { frequency: 1000, gain: 0, quality: 1, type: FilterTypeEnum.PK, }; -const DEFAULT_FILTERS: IFilter[] = FIXED_FREQUENCIES.map((f) => { +export const getDefaultFilter = () => { + return { + id: uid(8), + ...DEFAULT_FILTER_TEMPLATE, + }; +}; + +const getDefaultFilters = (): IFilter[] => + FIXED_FREQUENCIES.map((f) => { + return { ...getDefaultFilter(), frequency: f }; + }); + +export const getDefaultState = (): IState => { return { - frequency: f, - gain: 0, - quality: 1, - type: FilterTypeEnum.PK, + isEnabled: true, + isAutoPreAmpOn: true, + isGraphViewOn: false, + preAmp: 0, + filters: getDefaultFilters(), }; -}); - -export const DEFAULT_STATE: IState = { - isEnabled: true, - isAutoPreAmpOn: true, - isGraphViewOn: false, - preAmp: 0, - filters: DEFAULT_FILTERS, }; diff --git a/src/common/utils.ts b/src/common/utils.ts index 8547feee7..54541f65c 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -5,13 +5,10 @@ export const roundToPrecision = (value: number, precision: number) => { return Math.round(value * precisionFactor) / precisionFactor; }; -export const computeAvgFreq = (filters: IFilter[], insertIndex: number) => { - const lo = - insertIndex === 0 ? MIN_FREQUENCY : filters[insertIndex - 1].frequency; +export const computeAvgFreq = (filters: IFilter[], index: number) => { + const lo = index === 0 ? MIN_FREQUENCY : filters[index - 1].frequency; const hi = - insertIndex === filters.length - ? MAX_FREQUENCY - : filters[insertIndex].frequency; + index === filters.length ? MAX_FREQUENCY : filters[index].frequency; const exponent = (Math.log10(lo) + Math.log10(hi)) / 2; return roundToPrecision(10 ** exponent, 0); }; diff --git a/src/main/flush.ts b/src/main/flush.ts index 168ee27d8..af6ae956a 100644 --- a/src/main/flush.ts +++ b/src/main/flush.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { DEFAULT_STATE, IState } from '../common/constants'; +import { getDefaultState, IState } from '../common/constants'; export const stateToString = (state: IState) => { if (!state.isEnabled) { @@ -49,7 +49,7 @@ export const fetchSettings = () => { return JSON.parse(content) as IState; } catch (ex) { // if unable to fetch the state, use a default one - return DEFAULT_STATE; + return getDefaultState(); } }; diff --git a/src/main/main.ts b/src/main/main.ts index c9cceca1b..b8c2e6a28 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -12,6 +12,7 @@ import { app, BrowserWindow, ipcMain, shell } from 'electron'; import log from 'electron-log'; import { autoUpdater } from 'electron-updater'; import path from 'path'; +import { uid } from 'uid'; import { checkConfigFile, fetchSettings, @@ -41,6 +42,7 @@ import { import { ErrorCode } from '../common/errors'; import { computeAvgFreq } from '../common/utils'; import { TSuccess, TError } from '../renderer/utils/equalizerApi'; +import { sortHelper } from '../renderer/utils/utils'; export default class AppUpdater { constructor() { @@ -103,9 +105,10 @@ const updateConfigPath = async ( return true; }; -const handleUpdate = async ( +const handleUpdateHelper = async ( event: Electron.IpcMainEvent, - channel: ChannelEnum | string + channel: ChannelEnum | string, + response: T ) => { // Check whether EqualizerAPO is installed every time a change is made const isInstalled = await isEqualizerAPOInstalled(); @@ -123,13 +126,20 @@ const handleUpdate = async ( } // Return a success message of undefined - const reply: TSuccess = { result: undefined }; + const reply: TSuccess = { result: response }; event.reply(channel, reply); // Flush changes to our local state file after informing UI that the changes have been applied save(state); }; +const handleUpdate = async ( + event: Electron.IpcMainEvent, + channel: ChannelEnum | string +) => { + return handleUpdateHelper(event, channel, undefined); +}; + ipcMain.on(ChannelEnum.HEALTH_CHECK, async (event) => { const channel = ChannelEnum.HEALTH_CHECK; const res = await updateConfigPath(event, channel); @@ -352,13 +362,17 @@ ipcMain.on(ChannelEnum.ADD_FILTER, async (event, arg) => { } const frequency = computeAvgFreq(state.filters, insertIndex); - state.filters.splice(insertIndex, 0, { - frequency, - gain: 0, - quality: 1, - type: FilterTypeEnum.PK, - }); - await handleUpdate(event, channel); + const filterId = uid(8); + state.filters + .splice(insertIndex, 0, { + id: filterId, + frequency, + gain: 0, + quality: 1, + type: FilterTypeEnum.PK, + }) + .sort(sortHelper); + await handleUpdateHelper(event, channel, filterId); }); ipcMain.on(ChannelEnum.REMOVE_FILTER, async (event, arg) => { diff --git a/src/renderer/MainContent.tsx b/src/renderer/MainContent.tsx index e76d00bbf..8e04bc613 100644 --- a/src/renderer/MainContent.tsx +++ b/src/renderer/MainContent.tsx @@ -1,12 +1,14 @@ +import { createRef, Fragment } from 'react'; import { MAX_NUM_FILTERS, MIN_NUM_FILTERS } from 'common/constants'; import FrequencyBand from './components/FrequencyBand'; import { useAquaContext } from './utils/AquaContext'; import './styles/MainContent.scss'; import AddSliderDivider from './components/AddSliderDivider'; +import SortWrapper from './SortWrapper'; const MainContent = () => { const { filters } = useAquaContext(); - + const wrapperRef = createRef(); return (
@@ -20,36 +22,27 @@ const MainContent = () => { Gain (dB) Quality
-
+
= MAX_NUM_FILTERS} - // eslint-disable-next-line react/no-array-index-key - key={`add-slider-${-1}`} /> - {filters - .flatMap((filter, sliderIndex) => [ - { filter, sliderIndex }, - { sliderIndex }, - ]) - .map(({ filter, sliderIndex }) => - filter ? ( + + {filters.map((filter, sliderIndex) => ( + - ) : ( = MAX_NUM_FILTERS} - // eslint-disable-next-line react/no-array-index-key - key={`add-slider-${sliderIndex}`} /> - ) - )} + + ))} +
); diff --git a/src/renderer/SortWrapper.tsx b/src/renderer/SortWrapper.tsx new file mode 100644 index 000000000..0010d39f8 --- /dev/null +++ b/src/renderer/SortWrapper.tsx @@ -0,0 +1,185 @@ +import { + useState, + useEffect, + RefObject, + ReactElement, + useMemo, + useRef, + useLayoutEffect, +} from 'react'; + +interface IBoundingBoxMap { + [key: string]: DOMRect; +} + +type CustomElement = ReactElement & { ref?: RefObject }; + +// codesandbox.io/s/reorder-elements-with-slide-transition-and-react-hooks-flip-forked-wjojyy?file=/src/helpers/calculateBoundingBoxes.js:28-370 +export const calculateBoundingBoxes = ( + refs: RefObject[] +) => { + const boundingBoxes: IBoundingBoxMap = {}; + + refs.forEach((ref) => { + if (ref?.current) { + const domNode = ref.current; + const nodeBoundingBox = domNode.getBoundingClientRect(); + boundingBoxes[domNode.id] = nodeBoundingBox; + } + }); + + return boundingBoxes; +}; + +const getRefs = ( + element?: CustomElement +): RefObject[] | RefObject => { + if (!element) { + return []; + } + + if (element.ref) { + return element.ref; + } + + if (element.props.children) { + return element.props.children.flatMap((c: CustomElement) => getRefs(c)); + } + + return []; +}; + +const isBoundingBoxDifferent = ( + boundMap1: IBoundingBoxMap, + boundMap2: IBoundingBoxMap +) => { + const keys1 = Object.keys(boundMap1); + const keys2 = Object.keys(boundMap2); + + if (keys1.length !== keys2.length) { + return true; + } + + for (let i = 0; i < keys1.length; i += 1) { + if ( + !boundMap2[keys1[i]] || + boundMap1[keys1[i]].left !== boundMap2[keys1[i]].left + ) { + return true; + } + } + return false; +}; + +interface ISortWrapper { + children: CustomElement[]; + // This ref should refer to the parent html element that contains children + wrapperRef: RefObject; +} + +const SortWrapper = ({ + children = [], + wrapperRef, +}: ISortWrapper): JSX.Element => { + const [boundingBoxes, setBoundingBoxes] = useState({}); + const prevBoundingBoxesRef = useRef({}); + + // Latest scrollLeft snapshot. Snapshots are taken everytime children changes. + const [scrollLeft, setScrollLeft] = useState(0); + // The current scrollLeft value. This is NOT a snapshot. This updates everytime the user scrolls. + const interScrollLeft = useRef(0); + // The previous scrollLeft snapshot. + const prevScrollLeftRef = useRef(0); + + const refs: RefObject[] = useMemo( + () => children.flatMap((child) => getRefs(child)), + [children] + ); + + useEffect(() => { + let handler: NodeJS.Timeout; + const onScroll = () => { + if (handler) { + clearTimeout(handler); + } + handler = setTimeout(() => { + interScrollLeft.current = wrapperRef.current?.scrollLeft || 0; + }, 100); + }; + + const element = wrapperRef.current; + element?.addEventListener('scroll', onScroll); + return () => { + element?.removeEventListener('scroll', onScroll); + }; + }, [wrapperRef]); + + useLayoutEffect(() => { + // Update current children position information on first rerender thus triggering a second rerender + // Note that this information will not be updated in other useEffects until a second rerender + const newPositions = calculateBoundingBoxes(refs); + if (Object.keys(newPositions).length) { + setBoundingBoxes(newPositions); + } + }, [refs, wrapperRef]); + + useEffect(() => { + // Take a snapshot of the scrollLeft value of the wrapperRef + // Can only do this in a useEffect and not a useLayoutEffect because the wrapper element needs to have rendered first + setScrollLeft(wrapperRef.current?.scrollLeft || 0); + + const prevBoundingBoxes = prevBoundingBoxesRef?.current || {}; + + // Don't animate if the bounding box values haven't changed + // First rerender will trigger this useEffect because refs changed, but this check will return false + if (isBoundingBoxDifferent(prevBoundingBoxes, boundingBoxes)) { + // Most of the time, wrapperRef.scrollLeft is equal to interScrollLeft, but in the special case + // where the user scrolls all the way to the right and deletes a slider, wrapperRef.scrollLeft + // will have been reduced because wrapperRef.scrollWidth was reduced. + // Since we have no way of knowing if wrapperRef.scrollLeft was reduced from a + // reduced scrollWidth without tracking interScrollLeft, we will use interScrollLeft. + const scrollLeftDiff = + interScrollLeft.current - prevScrollLeftRef.current; + refs.forEach((ref) => { + if (ref?.current) { + const domNode = ref.current; + const firstBox = prevBoundingBoxes[domNode.id]; + const lastBox = boundingBoxes[domNode.id]; + // firstBox will be undefined for new filters + const changeInX = firstBox + ? firstBox.left - (lastBox.left + scrollLeftDiff) + : 0; + + if (changeInX) { + requestAnimationFrame(() => { + // Before the DOM paints, invert child to old position + domNode.style.transform = `translateX(${changeInX}px)`; + domNode.style.transition = 'transform 0s'; + + requestAnimationFrame(() => { + // After the previous frame, remove + // the transistion to play the animation + domNode.style.transform = ''; + domNode.style.transition = 'transform 500ms'; + }); + }); + } + } + }); + } + }, [boundingBoxes, refs, wrapperRef]); + + useEffect(() => { + // Update previous children position information on second rerender + prevBoundingBoxesRef.current = boundingBoxes; + }, [boundingBoxes]); + + useEffect(() => { + // Update previous scrollLeft information on second rerender + prevScrollLeftRef.current = scrollLeft; + }, [scrollLeft]); + + return <>{children}; +}; + +export default SortWrapper; diff --git a/src/renderer/components/AddSliderDivider.tsx b/src/renderer/components/AddSliderDivider.tsx index 16fd233f6..a607c153c 100644 --- a/src/renderer/components/AddSliderDivider.tsx +++ b/src/renderer/components/AddSliderDivider.tsx @@ -29,8 +29,12 @@ const AddSliderDivider = ({ setIsLoading(true); try { - await addEqualizerSlider(insertIndex); - dispatchFilter({ type: FilterActionEnum.ADD, index: insertIndex }); + const filterId = await addEqualizerSlider(insertIndex); + dispatchFilter({ + type: FilterActionEnum.ADD, + id: filterId, + index: insertIndex, + }); } catch (e) { setGlobalError(e as ErrorDescription); } diff --git a/src/renderer/components/FrequencyBand.tsx b/src/renderer/components/FrequencyBand.tsx index b068ee1a3..18e08a059 100644 --- a/src/renderer/components/FrequencyBand.tsx +++ b/src/renderer/components/FrequencyBand.tsx @@ -9,7 +9,13 @@ import { MIN_GAIN, MIN_QUALITY, } from 'common/constants'; -import { KeyboardEvent, useMemo, useState } from 'react'; +import { + ForwardedRef, + forwardRef, + KeyboardEvent, + useMemo, + useState, +} from 'react'; import { FILTER_OPTIONS } from '../icons/FilterTypeIcon'; import TrashIcon from '../icons/TrashIcon'; import Dropdown from '../widgets/Dropdown'; @@ -31,143 +37,145 @@ interface IFrequencyBandProps { isMinSliderCount: boolean; } -const FrequencyBand = ({ - sliderIndex, - filter, - isMinSliderCount, -}: IFrequencyBandProps) => { - const { globalError, setGlobalError, dispatchFilter } = useAquaContext(); - const [isLoading, setIsLoading] = useState(false); - const isRemoveDisabled = useMemo( - () => isMinSliderCount || isLoading, - [isLoading, isMinSliderCount] - ); +const FrequencyBand = forwardRef( + ( + { sliderIndex, filter, isMinSliderCount }: IFrequencyBandProps, + ref: ForwardedRef + ) => { + const { globalError, setGlobalError, dispatchFilter } = useAquaContext(); + const [isLoading, setIsLoading] = useState(false); + const isRemoveDisabled = useMemo( + () => isMinSliderCount || isLoading, + [isLoading, isMinSliderCount] + ); - const handleGainSubmit = async (newValue: number) => { - try { - await setGain(sliderIndex, newValue); - dispatchFilter({ - type: FilterActionEnum.GAIN, - index: sliderIndex, - newValue, - }); - } catch (e) { - setGlobalError(e as ErrorDescription); - } - }; + const handleGainSubmit = async (newValue: number) => { + try { + await setGain(sliderIndex, newValue); + dispatchFilter({ + type: FilterActionEnum.GAIN, + id: filter.id, + newValue, + }); + } catch (e) { + setGlobalError(e as ErrorDescription); + } + }; - const handleFrequencySubmit = async (newValue: number) => { - try { - await setFrequency(sliderIndex, newValue); - dispatchFilter({ - type: FilterActionEnum.FREQUENCY, - index: sliderIndex, - newValue, - }); - } catch (e) { - setGlobalError(e as ErrorDescription); - } - }; + const handleFrequencySubmit = async (newValue: number) => { + try { + await setFrequency(sliderIndex, newValue); + dispatchFilter({ + type: FilterActionEnum.FREQUENCY, + id: filter.id, + newValue, + }); + } catch (e) { + setGlobalError(e as ErrorDescription); + } + }; - const handleQualitySubmit = async (newValue: number) => { - try { - await setQuality(sliderIndex, newValue); - dispatchFilter({ - type: FilterActionEnum.QUALITY, - index: sliderIndex, - newValue, - }); - } catch (e) { - setGlobalError(e as ErrorDescription); - } - }; + const handleQualitySubmit = async (newValue: number) => { + try { + await setQuality(sliderIndex, newValue); + dispatchFilter({ + type: FilterActionEnum.QUALITY, + id: filter.id, + newValue, + }); + } catch (e) { + setGlobalError(e as ErrorDescription); + } + }; - const handleFilterTypeSubmit = async (newValue: string) => { - try { - await setType(sliderIndex, newValue); - dispatchFilter({ - type: FilterActionEnum.TYPE, - index: sliderIndex, - newValue: newValue as FilterTypeEnum, - }); - } catch (e) { - setGlobalError(e as ErrorDescription); - } - }; + const handleFilterTypeSubmit = async (newValue: string) => { + try { + await setType(sliderIndex, newValue); + dispatchFilter({ + type: FilterActionEnum.TYPE, + id: filter.id, + newValue: newValue as FilterTypeEnum, + }); + } catch (e) { + setGlobalError(e as ErrorDescription); + } + }; - const onRemoveEqualizerSlider = async () => { - if (isRemoveDisabled) { - return; - } + const onRemoveEqualizerSlider = async () => { + if (isRemoveDisabled) { + return; + } - setIsLoading(true); - try { - await removeEqualizerSlider(sliderIndex); - dispatchFilter({ type: FilterActionEnum.REMOVE, index: sliderIndex }); - } catch (e) { - setGlobalError(e as ErrorDescription); - } - setIsLoading(false); - }; + setIsLoading(true); + try { + await removeEqualizerSlider(sliderIndex); + dispatchFilter({ type: FilterActionEnum.REMOVE, id: filter.id }); + } catch (e) { + setGlobalError(e as ErrorDescription); + } + setIsLoading(false); + }; - const handleKeyUp = (e: KeyboardEvent) => { - if (e.code === 'Enter') { - onRemoveEqualizerSlider(); - } - }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === 'Enter') { + onRemoveEqualizerSlider(); + } + }; - return ( -
-
- -
-
- - -
- +
+ +
+
+ + +
+ +
+
-
-
- ); -}; + ); + } +); export default FrequencyBand; diff --git a/src/renderer/styles/MainContent.scss b/src/renderer/styles/MainContent.scss index 2ccd59e75..6b4cdf2bd 100644 --- a/src/renderer/styles/MainContent.scss +++ b/src/renderer/styles/MainContent.scss @@ -40,7 +40,7 @@ .bands { position: relative; - justify-content: space-between; + justify-content: flex-start; padding: 0 2px; // add some horizontal padding so the outline isn't cut off at either end width: 100%; overflow-x: auto; diff --git a/src/renderer/utils/AquaContext.tsx b/src/renderer/utils/AquaContext.tsx index 57488e70e..8022dc8df 100644 --- a/src/renderer/utils/AquaContext.tsx +++ b/src/renderer/utils/AquaContext.tsx @@ -7,15 +7,18 @@ import { useReducer, useState, } from 'react'; +import { uid } from 'uid'; import { - DEFAULT_STATE, FilterTypeEnum, + getDefaultFilter, + getDefaultState, IFilter, IState, } from '../../common/constants'; import { ErrorDescription } from '../../common/errors'; import { computeAvgFreq } from '../../common/utils'; import { getEqualizerState } from './equalizerApi'; +import { sortHelper } from './utils'; export enum FilterActionEnum { INIT, @@ -34,10 +37,10 @@ type NumericalFilterAction = export type FilterAction = | { type: FilterActionEnum.INIT; filters: IFilter[] } - | { type: NumericalFilterAction; index: number; newValue: number } - | { type: FilterActionEnum.TYPE; index: number; newValue: FilterTypeEnum } - | { type: FilterActionEnum.ADD; index: number } - | { type: FilterActionEnum.REMOVE; index: number }; + | { type: NumericalFilterAction; id: string; newValue: number } + | { type: FilterActionEnum.TYPE; id: string; newValue: FilterTypeEnum } + | { type: FilterActionEnum.ADD; id: string; index: number } + | { type: FilterActionEnum.REMOVE; id: string }; type FilterDispatch = (action: FilterAction) => void; @@ -63,36 +66,41 @@ const filterReducer: IFilterReducer = ( ) => { switch (action.type) { case FilterActionEnum.INIT: - return action.filters; + // Keeping the check for the id for backwards compatibility + // TODO: Remove the id check once this is no longer a concern + return action.filters + .map((filter) => (filter.id ? filter : { ...filter, id: uid(8) })) + .sort(sortHelper); case FilterActionEnum.FREQUENCY: - return filters.map((f, index) => - index === action.index ? { ...f, frequency: action.newValue } : f - ); + return filters + .map((f) => + f.id === action.id ? { ...f, frequency: action.newValue } : f + ) + .sort(sortHelper); case FilterActionEnum.GAIN: - return filters.map((f, index) => - index === action.index ? { ...f, gain: action.newValue } : f + return filters.map((f) => + f.id === action.id ? { ...f, gain: action.newValue } : f ); case FilterActionEnum.QUALITY: - return filters.map((f, index) => - index === action.index ? { ...f, quality: action.newValue } : f + return filters.map((f) => + f.id === action.id ? { ...f, quality: action.newValue } : f ); case FilterActionEnum.TYPE: - return filters.map((f, index) => - index === action.index ? { ...f, type: action.newValue } : f + return filters.map((f) => + f.id === action.id ? { ...f, type: action.newValue } : f ); case FilterActionEnum.ADD: return [ ...filters.slice(0, action.index), { + ...getDefaultFilter(), + id: action.id, frequency: computeAvgFreq(filters, action.index), - gain: 0, - quality: 1, - type: FilterTypeEnum.PK, }, ...filters.slice(action.index), - ]; + ].sort(sortHelper); case FilterActionEnum.REMOVE: - return filters.filter((_, index) => index !== action.index); + return filters.filter(({ id }) => id !== action.id); default: // This throw does not actually do anything because // we are in a reducer @@ -120,6 +128,9 @@ export const AquaProvider = ({ children }: IAquaProviderProps) => { const [globalError, setGlobalError] = useState< ErrorDescription | undefined >(); + + const DEFAULT_STATE = getDefaultState(); + const [isEnabled, setIsEnabled] = useState(DEFAULT_STATE.isEnabled); const [isAutoPreAmpOn, setAutoPreAmpOn] = useState( DEFAULT_STATE.isAutoPreAmpOn diff --git a/src/renderer/utils/equalizerApi.ts b/src/renderer/utils/equalizerApi.ts index bbdc3b938..63b9f87e0 100644 --- a/src/renderer/utils/equalizerApi.ts +++ b/src/renderer/utils/equalizerApi.ts @@ -331,10 +331,10 @@ export const getEqualizerSliderCount = (): Promise => { * Add another slider * @returns { Promise } exception if failed */ -export const addEqualizerSlider = (index: number): Promise => { +export const addEqualizerSlider = (index: number): Promise => { const channel = ChannelEnum.ADD_FILTER; window.electron.ipcRenderer.sendMessage(channel, [index]); - return promisifyResult(setterResponseHandler, channel); + return promisifyResult(simpleResponseHandler(), channel); }; /** diff --git a/src/renderer/utils/utils.ts b/src/renderer/utils/utils.ts index b2c85d953..5f7df7891 100644 --- a/src/renderer/utils/utils.ts +++ b/src/renderer/utils/utils.ts @@ -1,3 +1,4 @@ +import { IFilter } from 'common/constants'; import { RefObject, useEffect, useMemo, useRef } from 'react'; export const getMaxIntegerDigitCount = (num: number) => { @@ -9,6 +10,8 @@ export const clamp = (num: number, min: number, max: number) => { return Math.min(Math.max(num, min), max); }; +export const sortHelper = (a: IFilter, b: IFilter) => a.frequency - b.frequency; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#sequence_generator_range export const range = (start: number, stop: number, step: number) => Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step); @@ -92,3 +95,14 @@ export const useFocusOutside = ( return () => document.removeEventListener('focusin', handleFocus, true); }, [handleFocus]); }; + +// https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state +export const usePrevious = (value: T): T | undefined => { + const prevChildrenRef = useRef(); + + useEffect(() => { + prevChildrenRef.current = value; + }, [value]); + + return prevChildrenRef.current; +};