jam-tracks / frontend /src /components /AnalysisDisplay.jsx
Mina Emadi
Fix editable BPM input
8c53be9
Raw History Blame Contribute Delete
7.34 kB
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
const ALL_KEYS_WITH_MODES = [
'C major', 'C minor', 'C# major', 'C# minor',
'D major', 'D minor', 'D# major', 'D# minor',
'E major', 'E minor', 'F major', 'F minor',
'F# major', 'F# minor', 'G major', 'G minor',
'G# major', 'G# minor', 'A major', 'A minor',
'A# major', 'A# minor', 'B major', 'B minor'
]
const KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
const MIN_BPM = 20
const MAX_BPM = 400
function clampBpm(value) {
return Math.max(MIN_BPM, Math.min(MAX_BPM, value))
}
function formatBpm(value) {
if (!Number.isFinite(value)) return ''
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)))
}
function AnalysisDisplay({
detection,
loading,
onReady,
onLiveTransform
}) {
const [targetKeyWithMode, setTargetKeyWithMode] = useState('C major')
const [targetBpm, setTargetBpm] = useState(120)
const [targetBpmInput, setTargetBpmInput] = useState('120')
const readyDetectionRef = useRef(null)
const setBpmValue = useCallback((value) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return
const next = clampBpm(parsed)
setTargetBpm(next)
setTargetBpmInput(formatBpm(next))
}, [])
useEffect(() => {
if (detection && onReady && readyDetectionRef.current !== detection) {
readyDetectionRef.current = detection
onReady()
}
}, [detection, onReady])
useEffect(() => {
if (detection) {
setTargetKeyWithMode(`${detection.key} ${detection.mode}`)
setBpmValue(detection.bpm)
}
}, [detection, setBpmValue])
const targetKey = targetKeyWithMode.split(' ')[0]
const semitones = useMemo(() => {
if (!detection || !targetKey) return 0
const fromIdx = KEY_NAMES.indexOf(detection.key)
const toIdx = KEY_NAMES.indexOf(targetKey)
let diff = toIdx - fromIdx
if (diff > 6) diff -= 12
if (diff < -6) diff += 12
return diff
}, [detection, targetKey])
const handleKeyShift = useCallback((shift) => {
const currentIdx = KEY_NAMES.indexOf(targetKey)
const newIdx = (currentIdx + shift + 12) % 12
const mode = targetKeyWithMode.split(' ')[1]
setTargetKeyWithMode(`${KEY_NAMES[newIdx]} ${mode}`)
}, [targetKey, targetKeyWithMode])
const handleBpmInputChange = useCallback((event) => {
const rawValue = event.target.value
setTargetBpmInput(rawValue)
if (rawValue.trim() === '') return
const parsed = Number(rawValue)
if (Number.isFinite(parsed) && parsed >= MIN_BPM && parsed <= MAX_BPM) {
setTargetBpm(parsed)
}
}, [])
const handleBpmInputBlur = useCallback(() => {
const trimmedValue = targetBpmInput.trim()
const parsed = trimmedValue === '' ? NaN : Number(trimmedValue)
setBpmValue(Number.isFinite(parsed) ? parsed : targetBpm)
}, [setBpmValue, targetBpm, targetBpmInput])
const handleBpmStep = useCallback((delta) => {
setBpmValue(targetBpm + delta)
}, [setBpmValue, targetBpm])
useEffect(() => {
if (!detection || !onLiveTransform) return
onLiveTransform({
semitones,
originalBpm: detection.bpm,
targetBpm,
highQuality: true
})
}, [detection, onLiveTransform, semitones, targetBpm])
const hasChanges = detection && (semitones !== 0 || Math.abs(targetBpm - detection.bpm) > 0.1)
if (loading) {
return (
<div className="card rounded-xl p-5 animate-pulse">
<div className="flex gap-6">
<div className="h-12 bg-white/5 rounded-lg w-40"></div>
<div className="h-12 bg-white/5 rounded-lg w-40"></div>
</div>
</div>
)
}
if (!detection) return null
const btnClass = 'w-8 h-8 rounded-lg font-bold text-sm transition-all bg-white/[0.06] hover:bg-white/[0.12] text-gray-300 border border-white/[0.08] flex items-center justify-center flex-shrink-0'
const inputClass = 'bg-surface-elevated border border-white/[0.08] rounded-lg text-white text-sm text-center focus:outline-none focus:border-accent-500 transition-colors'
return (
<div className="card rounded-xl p-5 animate-fade-in">
<div className="flex items-center gap-6 flex-wrap">
{/* BPM Control: - [editable input] + */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-widest text-accent-500 font-medium mr-2">BPM</span>
<button onClick={() => handleBpmStep(-1)} className={btnClass}>-</button>
<input
type="number"
value={targetBpmInput}
onChange={handleBpmInputChange}
onBlur={handleBpmInputBlur}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
min={MIN_BPM}
max={MAX_BPM}
className={`${inputClass} w-20 px-2 py-1.5 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
/>
<button onClick={() => handleBpmStep(1)} className={btnClass}>+</button>
{Math.abs(targetBpm - detection.bpm) > 0.1 && (
<button
onClick={() => setBpmValue(detection.bpm)}
className="text-[10px] text-gray-400 hover:text-gray-200 transition-colors ml-1"
>
Reset
</button>
)}
</div>
{/* Divider */}
<div className="w-px h-10 bg-white/[0.08]"></div>
{/* Key Control: - [dropdown] + */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-widest text-accent-500 font-medium mr-2">Key</span>
<button onClick={() => handleKeyShift(-1)} className={btnClass}>-</button>
<select
value={targetKeyWithMode}
onChange={(e) => setTargetKeyWithMode(e.target.value)}
className={`${inputClass} w-28 px-2 py-1.5 cursor-pointer`}
>
{ALL_KEYS_WITH_MODES.map(k => (
<option key={k} value={k}>{k}</option>
))}
</select>
<button onClick={() => handleKeyShift(1)} className={btnClass}>+</button>
{semitones !== 0 && (
<span className="text-xs text-gray-400 font-mono ml-1">
{semitones > 0 ? '+' : ''}{semitones}st
</span>
)}
{semitones !== 0 && (
<button
onClick={() => setTargetKeyWithMode(`${detection.key} ${detection.mode}`)}
className="text-[10px] text-gray-400 hover:text-gray-200 transition-colors ml-1"
>
Reset
</button>
)}
</div>
<div className="w-px h-10 bg-white/[0.08]"></div>
<div className="px-3 py-1.5 rounded-lg border border-emerald-400/30 bg-emerald-400/10 text-emerald-300 text-[10px] uppercase tracking-widest font-semibold">
Live
</div>
{/* Original values */}
{(semitones !== 0 || Math.abs(targetBpm - detection.bpm) > 0.1) && (
<span className="text-[10px] text-gray-400 ml-auto">
Original: {detection.key} {detection.mode} / {detection.bpm.toFixed(1)} BPM
</span>
)}
</div>
</div>
)
}
export default AnalysisDisplay