jam-tracks / frontend /src /components /LiveChordView.jsx
Mina Emadi
Move player runtime to browser-only Node app
aad4714
Raw
History Blame Contribute Delete
4.06 kB
import React, { useMemo, useRef, useEffect, useState } from 'react'
import { transposeChord } from './ChordDisplay'
const VISIBLE_SIDES = 3 // number of chords to show on each side
function LiveChordView({ chordsData, currentTime, isPlaying, semitones = 0, onSeek }) {
const containerRef = useRef(null)
const [prevIndex, setPrevIndex] = useState(-1)
const chords = chordsData?.chords || []
const hasChords = chords.length > 0
// Binary search for active chord
const activeIndex = useMemo(() => {
if (!hasChords) return -1
let lo = 0, hi = chords.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
if (currentTime < chords[mid].start_time) hi = mid - 1
else if (currentTime >= chords[mid].end_time) lo = mid + 1
else return mid
}
return -1
}, [chords, currentTime, hasChords])
// Track previous index for transition direction
useEffect(() => {
if (activeIndex !== -1 && activeIndex !== prevIndex) {
setPrevIndex(activeIndex)
}
}, [activeIndex, prevIndex])
if (!chordsData || !hasChords) return null
// Progress within the current chord (0-1)
const chordProgress = activeIndex >= 0
? (() => {
const c = chords[activeIndex]
const span = c.end_time - c.start_time
return span > 0 ? Math.max(0, Math.min(1, (currentTime - c.start_time) / span)) : 0
})()
: 0
// Build the visible window of chords
const windowStart = Math.max(0, (activeIndex >= 0 ? activeIndex : 0) - VISIBLE_SIDES)
const windowEnd = Math.min(chords.length - 1, (activeIndex >= 0 ? activeIndex : 0) + VISIBLE_SIDES)
const visibleChords = []
for (let i = windowStart; i <= windowEnd; i++) {
visibleChords.push({ ...chords[i], index: i })
}
return (
<div className="card rounded-xl p-6 animate-fade-in">
{/* Teleprompter row */}
<div
ref={containerRef}
className="flex items-center justify-center gap-2 overflow-hidden py-4"
style={{ minHeight: '100px' }}
>
{visibleChords.map((entry) => {
const i = entry.index
const isActive = i === activeIndex
const isPast = activeIndex >= 0 && i < activeIndex
const isFuture = activeIndex >= 0 && i > activeIndex
const displayChord = transposeChord(entry.chord, semitones)
const isNC = displayChord === 'N.C.'
// Distance from active chord (0 = active, 1 = adjacent, etc.)
const distance = activeIndex >= 0 ? Math.abs(i - activeIndex) : 0
// Opacity and scale based on distance
let opacity, scale, textSize
if (isActive) {
opacity = 1
scale = 1
textSize = 'text-5xl'
} else if (distance === 1) {
opacity = isPast ? 0.35 : 0.5
scale = 0.7
textSize = 'text-2xl'
} else if (distance === 2) {
opacity = isPast ? 0.18 : 0.25
scale = 0.55
textSize = 'text-xl'
} else {
opacity = isPast ? 0.08 : 0.12
scale = 0.45
textSize = 'text-lg'
}
if (isNC && !isActive) {
opacity *= 0.4
}
return (
<button
key={i}
onClick={() => onSeek && onSeek(entry.start_time)}
className={`
flex-shrink-0 transition-all duration-300 ease-out cursor-pointer
font-bold tracking-tight px-3
${textSize}
${isActive ? 'text-white' : 'text-gray-400'}
`}
style={{
opacity,
transform: `scale(${scale})`,
}}
>
{displayChord}
</button>
)
})}
</div>
{/* Subtle label */}
{activeIndex >= 0 && (
<p className="text-center text-[10px] text-gray-400 mt-1 uppercase tracking-wider">
Now Playing
</p>
)}
</div>
)
}
export default React.memo(LiveChordView)