Spaces:
Sleeping
Sleeping
| 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) | |