forked from averyfelts/Lofi_Generator
work in progress implementation of: - mixer with channel strips, faders, pan knobs, level meters - timeline with ruler, playhead, sections, keyframe tracks - pattern and progression pickers for drums/chords - automation lanes and mute tracks - loop bracket for loop region selection - export modal placeholder known issues: - drum pattern changes don't update audio engine - timeline keyframes not connected to scheduler - some UI bugs remain this is a checkpoint commit for further iteration
95 lines
2.4 KiB
TypeScript
95 lines
2.4 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface PlayheadProps {
|
|
bar: number;
|
|
beat: number;
|
|
pixelsPerBar: number;
|
|
height: number;
|
|
durationBars: number;
|
|
onSeek?: (bar: number, beat: number) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function Playhead({
|
|
bar,
|
|
beat,
|
|
pixelsPerBar,
|
|
height,
|
|
durationBars,
|
|
onSeek,
|
|
className,
|
|
}: PlayheadProps) {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const pixelsPerBeat = pixelsPerBar / 4;
|
|
const position = bar * pixelsPerBar + beat * pixelsPerBeat;
|
|
|
|
const handleDrag = useCallback(
|
|
(clientX: number, containerLeft: number) => {
|
|
if (!onSeek) return;
|
|
|
|
const x = clientX - containerLeft;
|
|
const totalBeats = x / pixelsPerBeat;
|
|
const newBar = Math.floor(totalBeats / 4);
|
|
const newBeat = Math.floor(totalBeats % 4);
|
|
|
|
if (newBar >= 0 && newBar < durationBars) {
|
|
onSeek(newBar, Math.max(0, Math.min(3, newBeat)));
|
|
}
|
|
},
|
|
[onSeek, pixelsPerBeat, durationBars]
|
|
);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!onSeek) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(true);
|
|
|
|
const container = (e.currentTarget as HTMLElement).parentElement;
|
|
if (!container) return;
|
|
const containerRect = container.getBoundingClientRect();
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
handleDrag(e.clientX, containerRect.left);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
},
|
|
[onSeek, handleDrag]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'absolute top-0 z-20',
|
|
'flex flex-col items-center',
|
|
className
|
|
)}
|
|
style={{ left: position, height }}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'w-4 h-3 bg-lofi-orange rounded-sm -mt-0.5 cursor-grab',
|
|
isDragging && 'cursor-grabbing scale-110'
|
|
)}
|
|
style={{
|
|
clipPath: 'polygon(50% 100%, 0% 0%, 100% 0%)',
|
|
}}
|
|
onMouseDown={handleMouseDown}
|
|
/>
|
|
<div className="w-0.5 flex-1 bg-lofi-orange shadow-[0_0_8px_var(--lofi-orange)] pointer-events-none" />
|
|
</div>
|
|
);
|
|
}
|