Skip to content

Commit 6271d09

Browse files
authored
[C-4896] [C-4893] [C-4913] BPM precision adjustments (#9349)
1 parent dc2a681 commit 6271d09

File tree

11 files changed

+126
-56
lines changed

11 files changed

+126
-56
lines changed

packages/common/src/api/search.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ const getMinMaxFromBpm = (bpm?: string) => {
3434
const bpmParts = bpm ? bpm.split('-') : [undefined, undefined]
3535
const bpmMin = bpmParts[0] ? parseFloat(bpmParts[0]) : undefined
3636
const bpmMax = bpmParts[1] ? parseFloat(bpmParts[1]) : bpmMin
37-
return [bpmMin, bpmMax]
37+
38+
// Because we round the bpm display to the nearest whole number, we need to add a small buffer
39+
const bufferedBpmMin = bpmMin ? Math.round(bpmMin) - 0.5 : undefined
40+
const bufferedBpmMax = bpmMax ? Math.round(bpmMax) + 0.5 : undefined
41+
42+
return [bufferedBpmMin, bufferedBpmMax]
3843
}
3944

4045
const searchApi = createApi({

packages/common/src/hooks/useTrackMetadata.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export const useTrackMetadata = ({
4545
mood,
4646
is_unlisted: isUnlisted,
4747
musical_key,
48-
bpm
48+
bpm,
49+
is_custom_bpm: isCustomBpm
4950
} = track
5051

5152
const labels = [
@@ -73,7 +74,7 @@ export const useTrackMetadata = ({
7374
{
7475
id: TrackMetadataType.BPM,
7576
label: 'BPM',
76-
value: bpm ? Math.round(bpm ?? 0).toString() : '',
77+
value: bpm ? (bpm ?? 0).toFixed(isCustomBpm ? 2 : 0).toString() : '',
7778
isHidden: !isSearchV2Enabled
7879
},
7980
{

packages/common/src/models/Track.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ export type TrackMetadata = {
239239
producer_copyright_line?: Copyright | null
240240
parental_warning_type?: string | null
241241
bpm?: number | null
242+
is_custom_bpm?: boolean
242243
musical_key?: string | null
243244
audio_analysis_error_count?: number
244245

packages/common/src/schemas/metadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const trackMetadataSchema = {
6464
parental_warning_type: null,
6565
allowed_api_keys: null,
6666
bpm: null,
67+
is_custom_bpm: false,
6768
musical_key: null,
6869
audio_analysis_error_count: 0
6970
}

packages/common/src/utils/bpm.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
export const BPM_REGEX = /^\d{0,3}(\.\d{0,2})?$/
12
/**
23
* Check if a bpm is valid
34
* It should be a string containing number with up to 3 digits before the decimal and
4-
* up to 1 digit after the decimal
5+
* up to 2 digits after the decimal
56
*/
67
export const isBpmValid = (bpm: string): boolean => {
7-
const regex = /^\d{0,3}(\.\d{0,1})?$/
8+
const regex = BPM_REGEX
89
return regex.test(bpm)
910
}

packages/mobile/src/screens/search-screen-v2/screens/FilterBpmScreen.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useCallback, useEffect, useMemo, useState } from 'react'
22

3+
import { isBpmValid } from '@audius/common/utils'
4+
35
import { Button, Flex, Text, TextInput, useTheme } from '@audius/harmony-native'
46
import { KeyboardAvoidingView, SegmentedControl } from 'app/components/core'
57
import { FormScreen } from 'app/screens/form-screen'
@@ -166,9 +168,10 @@ const BpmRangeView = ({ value, setValue }: ViewProps) => {
166168
helperText={minError}
167169
aria-errormessage={minError ?? undefined}
168170
placeholder={messages.minBpm}
169-
onChangeText={(text) => {
170-
const validated = text.match(/^(\d{0,3}$)/)
171-
if (validated) setMinBpm(text)
171+
onChangeText={(value) => {
172+
if (value === '' || isBpmValid(value)) {
173+
setMinBpm(value)
174+
}
172175
}}
173176
/>
174177
<Text style={{ alignSelf: 'flex-start', paddingVertical: 20 }}>-</Text>
@@ -181,9 +184,10 @@ const BpmRangeView = ({ value, setValue }: ViewProps) => {
181184
helperText={maxError}
182185
aria-errormessage={maxError ?? undefined}
183186
placeholder={messages.maxBpm}
184-
onChangeText={(text) => {
185-
const validated = text.match(/^(\d{0,3}$)/)
186-
if (validated) setMaxBpm(text)
187+
onChangeText={(value) => {
188+
if (value === '' || isBpmValid(value)) {
189+
setMaxBpm(value)
190+
}
187191
}}
188192
/>
189193
</Flex>
@@ -283,8 +287,9 @@ const BpmTargetView = ({ value, setValue }: ViewProps) => {
283287
placeholder={messages.bpm}
284288
value={bpmTarget}
285289
onChangeText={(text) => {
286-
const validated = text.match(/^(\d{0,3}$)/)
287-
if (validated) setBpmTarget(text)
290+
if (text === '' || isBpmValid(text)) {
291+
setBpmTarget(text)
292+
}
288293
}}
289294
/>
290295
<Flex direction='row' gap='s'>
@@ -309,7 +314,7 @@ const BpmTargetView = ({ value, setValue }: ViewProps) => {
309314
export const FilterBpmScreen = () => {
310315
const [bpm, setBpm, clearBpm] = useSearchFilter('bpm')
311316
const [bpmType, setBpmType] = useSearchBpmType()
312-
const [bpmValue, setBpmValue] = useState(bpm)
317+
const [bpmValue, setBpmValue] = useState(isBpmValid(bpm ?? '') ? bpm : '')
313318

314319
const handleSubmit = useCallback(() => {
315320
if (bpmValue) {

packages/web/src/common/store/cache/tracks/sagas.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,13 @@ function* editTrackAsync(action: ReturnType<typeof trackActions.editTrack>) {
177177
const trackForEdit = yield* addPremiumMetadata(action.formFields)
178178

179179
// Format musical key
180-
trackForEdit.musical_key = formatMusicalKey(
181-
trackForEdit.musical_key || undefined
182-
)
180+
trackForEdit.musical_key =
181+
formatMusicalKey(trackForEdit.musical_key || undefined) ?? null
183182

184183
// Format bpm
185-
trackForEdit.bpm = trackForEdit.bpm ? Number(trackForEdit.bpm) : undefined
184+
trackForEdit.bpm = trackForEdit.bpm ? Number(trackForEdit.bpm) : null
185+
trackForEdit.is_custom_bpm =
186+
currentTrack.is_custom_bpm || trackForEdit.bpm !== currentTrack.bpm
186187

187188
yield* call(
188189
confirmEditTrack,

packages/web/src/components/edit/fields/AdvancedField.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { TextField } from 'components/form-fields'
3434
import { SegmentedControlField } from 'components/form-fields/SegmentedControlField'
3535
import layoutStyles from 'components/layout/layout.module.css'
3636
import { Tooltip } from 'components/tooltip'
37+
import { useBpmMaskedInput } from 'hooks/useBpmMaskedInput'
3738
import { env } from 'services/env'
3839

3940
import styles from './AdvancedField.module.css'
@@ -256,7 +257,7 @@ export const AdvancedField = ({ isUpload }: AdvancedFieldProps) => {
256257
).licenseType
257258
)
258259
const bpmValue = get(values, BPM)
259-
setBpm(bpmValue ? Number(bpmValue) : bpm)
260+
setBpm(typeof bpmValue !== 'undefined' ? Number(bpmValue) : bpm)
260261
setMusicalKey(get(values, MUSICAL_KEY) ?? musicalKey)
261262
setReleaseDate(get(values, RELEASE_DATE) ?? releaseDate)
262263
},
@@ -355,6 +356,12 @@ const AdvancedModalFields = ({ isUpload }: { isUpload?: boolean }) => {
355356
const [{ value: isHidden }] = useField<boolean>(IS_UNLISTED)
356357
const [, , { setValue: setBpmValue }] = useField<string>(BPM)
357358

359+
const bpmMaskedInputProps = useBpmMaskedInput({
360+
onChange: (e) => {
361+
setBpmValue(e.target.value)
362+
}
363+
})
364+
358365
const { licenseType, licenseDescription } = computeLicense(
359366
allowAttribution,
360367
commercialUse,
@@ -501,15 +508,9 @@ const AdvancedModalFields = ({ isUpload }: { isUpload?: boolean }) => {
501508
<TextField
502509
name={BPM}
503510
type='number'
504-
onChange={(e) => {
505-
const { value } = e.nativeEvent.target as HTMLInputElement
506-
507-
if (value === '' || isBpmValid(value)) {
508-
setBpmValue(value)
509-
}
510-
}}
511511
label={messages.bpm.label}
512512
autoComplete='off'
513+
{...bpmMaskedInputProps}
513514
/>
514515
</Flex>
515516
<Flex direction='column' w='100%'>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { BPM_REGEX } from '@audius/common/utils'
2+
3+
import {
4+
UseRegexMaskedInputParams,
5+
useRegexMaskedInput
6+
} from './useRegexMaskedInput'
7+
8+
type UseBpmMaskedInputParams = Omit<UseRegexMaskedInputParams, 'regex'>
9+
10+
/**
11+
* Mask BPM input values
12+
*
13+
* @example
14+
* const maskedInputProps = useBpmMaskedInput()
15+
* return <input {...maskedInputProps} />
16+
*/
17+
export const useBpmMaskedInput = (params?: UseBpmMaskedInputParams) =>
18+
useRegexMaskedInput({ regex: BPM_REGEX, onChange: params?.onChange })
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ChangeEvent, useRef } from 'react'
2+
3+
export type UseRegexMaskedInputParams = {
4+
regex: RegExp
5+
onChange?: (event: ChangeEvent<HTMLInputElement>) => void
6+
}
7+
8+
/**
9+
* Mask input values based on a regex pattern.
10+
*
11+
* @example
12+
* const maskedInputProps = useRegexMaskedInput({ regex: /^\d{0,4}$/ })
13+
* return <input {...maskedInputProps} />
14+
*/
15+
export const useRegexMaskedInput = (params: UseRegexMaskedInputParams) => {
16+
const { regex, onChange } = params
17+
const ref = useRef<HTMLInputElement>(null)
18+
const previousValidValue = useRef('')
19+
20+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
21+
const input = event.target
22+
const value = input.value
23+
24+
if (regex.test(value)) {
25+
// If the value matches the regex, update the previous valid value
26+
previousValidValue.current = value
27+
onChange?.(event)
28+
} else {
29+
// If the value doesn't match the regex, revert to the previous valid value
30+
input.value = previousValidValue.current
31+
}
32+
}
33+
return {
34+
ref,
35+
onChange: handleChange
36+
}
37+
}

packages/web/src/pages/search-page-v2/BpmFilter.tsx

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useMemo, useState } from 'react'
22

33
import { useStateDebounced } from '@audius/common/hooks'
4+
import { isBpmValid } from '@audius/common/utils'
45
import {
56
Box,
67
Button,
@@ -18,15 +19,13 @@ import {
1819
import { css } from '@emotion/css'
1920
import { useSearchParams } from 'react-router-dom-v5-compat'
2021

22+
import { useBpmMaskedInput } from 'hooks/useBpmMaskedInput'
23+
2124
import { useUpdateSearchParams } from './utils'
2225

2326
const MIN_BPM = 1
2427
const MAX_BPM = 999
2528

26-
const stripLeadingZeros = (string: string) => {
27-
return Number(string).toString()
28-
}
29-
3029
type BpmTargetType = 'exact' | 'range5' | 'range10'
3130
const targetOptions: { label: string; value: BpmTargetType }[] = [
3231
{
@@ -107,6 +106,20 @@ const BpmRangeView = ({ value, handleChange }: ViewProps) => {
107106
// eslint-disable-next-line react-hooks/exhaustive-deps
108107
const onChange = useMemo(() => handleChange, [])
109108

109+
const minBpmMaskedInputProps = useBpmMaskedInput({
110+
onChange: (e) => {
111+
setHasChanged(true)
112+
setMinBpm(e.target.value)
113+
}
114+
})
115+
116+
const maxBpmMaskedInputProps = useBpmMaskedInput({
117+
onChange: (e) => {
118+
setHasChanged(true)
119+
setMaxBpm(e.target.value)
120+
}
121+
})
122+
110123
useEffect(() => {
111124
if (!hasChanged) return
112125

@@ -189,15 +202,8 @@ const BpmRangeView = ({ value, handleChange }: ViewProps) => {
189202
aria-errormessage={minError ?? undefined}
190203
placeholder={messages.minBpm}
191204
hideLabel
192-
onInput={(e) => {
193-
const input = e.nativeEvent.target as HTMLInputElement
194-
input.value = input.value.slice(0, input.maxLength)
195-
}}
196-
onChange={(e) => {
197-
setHasChanged(true)
198-
setMinBpm(stripLeadingZeros(e.target.value))
199-
}}
200205
inputRootClassName={css({ height: '48px !important' })}
206+
{...minBpmMaskedInputProps}
201207
/>
202208
<Box pv='l'>-</Box>
203209
<TextInput
@@ -210,15 +216,8 @@ const BpmRangeView = ({ value, handleChange }: ViewProps) => {
210216
aria-errormessage={maxError ?? undefined}
211217
placeholder={messages.maxBpm}
212218
hideLabel
213-
onInput={(e) => {
214-
const input = e.nativeEvent.target as HTMLInputElement
215-
input.value = input.value.slice(0, input.maxLength)
216-
}}
217-
onChange={(e) => {
218-
setHasChanged(true)
219-
setMaxBpm(stripLeadingZeros(e.target.value))
220-
}}
221219
inputRootClassName={css({ height: '48px !important' })}
220+
{...maxBpmMaskedInputProps}
222221
/>
223222
</Flex>
224223
</>
@@ -250,6 +249,13 @@ const BpmTargetView = ({ value, handleChange }: ViewProps) => {
250249
// eslint-disable-next-line react-hooks/exhaustive-deps
251250
const onChange = useMemo(() => handleChange, [])
252251

252+
const bpmMaskedInputProps = useBpmMaskedInput({
253+
onChange: (e) => {
254+
setHasChanged(true)
255+
setBpmTarget(e.target.value)
256+
}
257+
})
258+
253259
useEffect(() => {
254260
if (!hasChanged) return
255261

@@ -304,21 +310,13 @@ const BpmTargetView = ({ value, handleChange }: ViewProps) => {
304310
defaultValue={initialTargetValue ?? ''}
305311
label={messages.bpm}
306312
type='number'
307-
maxLength={3}
308313
error={!!error}
309314
helperText={error}
310315
aria-errormessage={error ?? undefined}
311316
placeholder={messages.bpm}
312317
hideLabel
313-
onInput={(e) => {
314-
const input = e.nativeEvent.target as HTMLInputElement
315-
input.value = input.value.slice(0, input.maxLength)
316-
}}
317-
onChange={(e) => {
318-
setHasChanged(true)
319-
setBpmTarget(stripLeadingZeros(e.target.value))
320-
}}
321318
inputRootClassName={css({ height: '48px !important' })}
319+
{...bpmMaskedInputProps}
322320
/>
323321
<Flex justifyContent='center' alignItems='center' gap='xs'>
324322
{targetOptions.map((option) => (
@@ -349,6 +347,7 @@ const BpmTargetView = ({ value, handleChange }: ViewProps) => {
349347
export const BpmFilter = () => {
350348
const [urlSearchParams] = useSearchParams()
351349
const bpm = urlSearchParams.get('bpm')
350+
const validatedBpm = isBpmValid(bpm ?? '') ? bpm : null
352351
const updateSearchParams = useUpdateSearchParams('bpm')
353352
const [bpmFilterType, setBpmFilterType] = useState<'range' | 'target'>(
354353
'range'
@@ -398,7 +397,7 @@ export const BpmFilter = () => {
398397
/>
399398
</Flex>
400399
<Divider css={{ width: '100%' }} />
401-
<InputView value={bpm} handleChange={handleChange} />
400+
<InputView value={validatedBpm} handleChange={handleChange} />
402401
</Flex>
403402
</Paper>
404403
</Popup>

0 commit comments

Comments
 (0)