Spaces:
Sleeping
Sleeping
| import { useEffect, useRef, useCallback } from 'react'; | |
| import { COLORS } from '../utils/colorScheme'; | |
| const LOOK_AHEAD = 4; // seconds ahead to show | |
| const STRING_LABELS = ['G', 'D', 'A', 'E']; // high to low (display order) | |
| export default function BassTab({ | |
| tabData, | |
| currentTimeRef, | |
| width, | |
| height, | |
| }) { | |
| const canvasRef = useRef(null); | |
| const animRef = useRef(null); | |
| const draw = useCallback(() => { | |
| const canvas = canvasRef.current; | |
| if (!canvas || !tabData) return; | |
| const ctx = canvas.getContext('2d'); | |
| const dpr = window.devicePixelRatio || 1; | |
| if (canvas.width !== width * dpr || canvas.height !== height * dpr) { | |
| canvas.width = width * dpr; | |
| canvas.height = height * dpr; | |
| canvas.style.width = `${width}px`; | |
| canvas.style.height = `${height}px`; | |
| ctx.scale(dpr, dpr); | |
| } | |
| const currentTime = currentTimeRef?.current ?? 0; | |
| const numStrings = tabData.strings || 4; | |
| // Layout | |
| const topMargin = 50; | |
| const bottomMargin = 30; | |
| const leftMargin = 40; | |
| const rightMargin = 20; | |
| const stringAreaHeight = height - topMargin - bottomMargin; | |
| const stringSpacing = stringAreaHeight / (numStrings - 1); | |
| const playableWidth = width - leftMargin - rightMargin; | |
| // Progress line at 20% from left | |
| const progressX = leftMargin + playableWidth * 0.2; | |
| const pixelsPerSecond = (playableWidth * 0.8) / LOOK_AHEAD; | |
| // Clear | |
| ctx.fillStyle = COLORS.tabBg; | |
| ctx.fillRect(0, 0, width, height); | |
| // Draw string labels | |
| ctx.font = '13px Inter, monospace'; | |
| ctx.textAlign = 'right'; | |
| ctx.textBaseline = 'middle'; | |
| for (let i = 0; i < numStrings; i++) { | |
| const y = topMargin + i * stringSpacing; | |
| ctx.fillStyle = COLORS.textMuted; | |
| // Display high string (index 3) at top, low string (index 0) at bottom | |
| const labelIdx = numStrings - 1 - i; | |
| ctx.fillText(STRING_LABELS[labelIdx] || '', leftMargin - 10, y); | |
| } | |
| // Draw string lines | |
| ctx.strokeStyle = COLORS.tabString; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i < numStrings; i++) { | |
| const y = topMargin + i * stringSpacing; | |
| ctx.beginPath(); | |
| ctx.moveTo(leftMargin, y); | |
| ctx.lineTo(width - rightMargin, y); | |
| ctx.stroke(); | |
| } | |
| // Draw fret numbers | |
| const events = tabData.events || []; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.font = 'bold 14px Inter, monospace'; | |
| for (const event of events) { | |
| const x = progressX + (event.time - currentTime) * pixelsPerSecond; | |
| if (x < leftMargin - 30 || x > width + 30) continue; | |
| const isPast = event.time <= currentTime; | |
| const isActive = isPast && event.time + event.duration > currentTime; | |
| for (let s = 0; s < numStrings; s++) { | |
| const fret = event.frets[s]; | |
| if (fret === null || fret === undefined) continue; | |
| // String display: index 0 (low E) at bottom, index 3 (G) at top | |
| const displayRow = numStrings - 1 - s; | |
| const y = topMargin + displayRow * stringSpacing; | |
| const textWidth = ctx.measureText(String(fret)).width; | |
| const pillW = Math.max(textWidth + 8, 18); | |
| const pillH = 18; | |
| if (isActive) { | |
| ctx.fillStyle = 'rgba(139, 92, 246, 0.3)'; | |
| ctx.beginPath(); | |
| ctx.roundRect(x - pillW / 2, y - pillH / 2, pillW, pillH, 4); | |
| ctx.fill(); | |
| } | |
| // Clear string line behind the number | |
| ctx.fillStyle = COLORS.tabBg; | |
| ctx.fillRect(x - pillW / 2, y - pillH / 2, pillW, pillH); | |
| // Fret number | |
| ctx.fillStyle = isPast ? COLORS.tabFretPlayed : COLORS.tabFret; | |
| ctx.fillText(String(fret), x, y); | |
| } | |
| } | |
| // Progress line | |
| ctx.strokeStyle = COLORS.tabProgressLine; | |
| ctx.lineWidth = 2; | |
| ctx.shadowColor = 'rgba(139, 92, 246, 0.5)'; | |
| ctx.shadowBlur = 8; | |
| ctx.beginPath(); | |
| ctx.moveTo(progressX, topMargin - 10); | |
| ctx.lineTo(progressX, topMargin + (numStrings - 1) * stringSpacing + 10); | |
| ctx.stroke(); | |
| ctx.shadowBlur = 0; | |
| // "TAB" clef | |
| ctx.fillStyle = COLORS.textMuted; | |
| ctx.font = 'bold 16px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| const tabY = topMargin + stringAreaHeight / 2; | |
| ctx.fillText('T', 15, tabY - 12); | |
| ctx.fillText('A', 15, tabY); | |
| ctx.fillText('B', 15, tabY + 12); | |
| animRef.current = requestAnimationFrame(draw); | |
| }, [tabData, currentTimeRef, width, height]); | |
| useEffect(() => { | |
| animRef.current = requestAnimationFrame(draw); | |
| return () => { | |
| if (animRef.current) cancelAnimationFrame(animRef.current); | |
| }; | |
| }, [draw]); | |
| if (!tabData) { | |
| return ( | |
| <div className="tab-empty"> | |
| <p>No bass tab data available.</p> | |
| </div> | |
| ); | |
| } | |
| return <canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />; | |
| } | |