Files
ag-beats/public/components/Sequencer.tsx
2025-12-20 16:32:05 +02:00

350 lines
20 KiB
TypeScript

import React, { useRef, useState, useEffect } from 'react';
import { INSTRUMENTS, BASS_NOTES, MIN_TEMPO, MAX_TEMPO, MIN_STEPS, MAX_STEPS } from '../constants';
import { PlayIcon, StopIcon, ClearIcon, UploadIcon, DownloadIcon, PlusIcon, MinusIcon, MuteIcon, UnmuteIcon } from './icons';
import { Grid, BassLineGrid } from '../types';
import { Modal } from './Modal';
interface SequencerProps {
steps: number;
grid: Grid;
bassLine: BassLineGrid;
isPlaying: boolean;
tempo: number;
currentStep: number | null;
mutes: boolean[];
drumVolume: number;
bassVolume: number;
setStep: (instrumentIndex: number, stepIndex: number, isActive: boolean) => void;
setBassNote: (stepIndex: number, note: string) => void;
clearPattern: () => void;
setTempo: React.Dispatch<React.SetStateAction<number>>;
handleStepsChange: (newSteps: number) => void;
startPlayback: () => Promise<void>;
stopPlayback: () => void;
exportBeat: () => void;
importBeat: (event: React.ChangeEvent<HTMLInputElement>) => void;
toggleMute: (instrumentIndex: number) => void;
handleDrumVolumeChange: (newVolume: number) => void;
handleBassVolumeChange: (newVolume: number) => void;
triggerSound: (instrumentName: string) => void;
triggerBassNote: (note: string) => void;
}
const SequencerButton: React.FC<{ onClick?: () => void; children: React.ReactNode; className?: string; disabled?: boolean }> = ({ onClick, children, className, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all ${className}`}
>
{children}
</button>
);
export const Sequencer: React.FC<SequencerProps> = ({
steps, grid, isPlaying, tempo, currentStep, mutes, bassLine,
drumVolume, bassVolume,
clearPattern, setTempo, handleStepsChange, setStep, setBassNote,
startPlayback, stopPlayback, exportBeat, importBeat, toggleMute,
handleDrumVolumeChange, handleBassVolumeChange, triggerSound, triggerBassNote
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragActivationState, setDragActivationState] = useState(false);
const [dragStartRow, setDragStartRow] = useState<number | null>(null);
const [isBassDragging, setIsBassDragging] = useState(false);
const [bassDragActivationState, setBassDragActivationState] = useState(false);
const [isClearModalOpen, setIsClearModalOpen] = useState(false);
useEffect(() => {
const handleMouseUp = () => {
setIsDragging(false);
setDragStartRow(null);
setIsBassDragging(false);
};
window.addEventListener('mouseup', handleMouseUp);
return () => window.removeEventListener('mouseup', handleMouseUp);
}, []);
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleCellMouseDown = (instIndex: number, stepIndex: number) => {
setIsDragging(true);
setDragStartRow(instIndex);
const newState = !grid[instIndex]?.[stepIndex];
setDragActivationState(newState);
setStep(instIndex, stepIndex, newState);
if (newState && !isPlaying) {
triggerSound(INSTRUMENTS[instIndex].name);
}
};
const handleCellMouseEnter = (instIndex: number, stepIndex: number) => {
if (isDragging && instIndex === dragStartRow) {
setStep(instIndex, stepIndex, dragActivationState);
}
};
const handleBassCellMouseDown = (noteName: string, stepIndex: number) => {
const isCurrentlyActive = bassLine[stepIndex]?.includes(noteName);
const newState = !isCurrentlyActive;
setBassNote(stepIndex, noteName);
setIsBassDragging(true);
setBassDragActivationState(newState);
if (newState && !isPlaying) {
triggerBassNote(noteName);
}
};
const handleBassCellMouseEnter = (noteName: string, stepIndex: number) => {
if (isBassDragging) {
const isCurrentlyActive = bassLine[stepIndex]?.includes(noteName);
if (bassDragActivationState !== isCurrentlyActive) {
setBassNote(stepIndex, noteName);
}
}
};
const incrementTempo = () => setTempo(t => Math.min(MAX_TEMPO, t + 1));
const decrementTempo = () => setTempo(t => Math.max(MIN_TEMPO, t - 1));
const handleTempoScroll = (e: React.WheelEvent<HTMLDivElement>) => {
e.preventDefault();
if (e.deltaY < 0) {
decrementTempo();
} else {
incrementTempo();
}
};
const incrementSteps = () => {
const newSteps = steps + 4;
if (newSteps <= MAX_STEPS) {
handleStepsChange(newSteps);
}
};
const decrementSteps = () => {
const newSteps = steps - 4;
if (newSteps >= MIN_STEPS) {
handleStepsChange(newSteps);
}
};
const handleClearConfirm = () => {
clearPattern();
setIsClearModalOpen(false);
};
return (
<>
<Modal
isOpen={isClearModalOpen}
onClose={() => setIsClearModalOpen(false)}
onConfirm={handleClearConfirm}
title="Clear Session"
>
<p>Are you sure you want to clear the session? This will erase all current progress.</p>
</Modal>
<div className={`bg-white p-4 sm:p-6 rounded-xl shadow-lg border border-slate-200 ${isDragging || isBassDragging ? 'select-none' : ''}`}>
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-2">
{!isPlaying ? (
<SequencerButton onClick={startPlayback} title="Play">
<PlayIcon className="w-5 h-5" /> <span className="hidden lg:inline">Play</span>
</SequencerButton>
) : (
<SequencerButton onClick={stopPlayback} title="Stop">
<StopIcon className="w-5 h-5" /> <span className="hidden lg:inline">Stop</span>
</SequencerButton>
)}
<SequencerButton onClick={() => setIsClearModalOpen(true)} title="Clear">
<ClearIcon className="w-5 h-5" /> <span className="hidden lg:inline">Clear</span>
</SequencerButton>
</div>
<div className="flex items-center gap-x-6 gap-y-2 flex-wrap justify-center">
<div className="flex items-center gap-3">
<label htmlFor="tempo-display" className="text-sm font-medium text-slate-600">Tempo</label>
<div
className="flex items-center border border-slate-300 rounded-md shadow-sm"
onWheel={handleTempoScroll}
title="Scroll to adjust tempo"
>
<button
onClick={decrementTempo}
disabled={tempo <= MIN_TEMPO}
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-l-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
aria-label="Decrease tempo"
>
<MinusIcon className="w-5 h-5" />
</button>
<span
id="tempo-display"
className="px-3 py-2 text-sm font-mono text-slate-700 w-24 text-center border-l border-r border-slate-300 bg-white"
>
{tempo} BPM
</span>
<button
onClick={incrementTempo}
disabled={tempo >= MAX_TEMPO}
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-r-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
aria-label="Increase tempo"
>
<PlusIcon className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex items-center gap-3">
<label htmlFor="steps-display" className="text-sm font-medium text-slate-600">Steps</label>
<div className="flex items-center border border-slate-300 rounded-md shadow-sm">
<button
onClick={decrementSteps}
disabled={steps <= MIN_STEPS}
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-l-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
aria-label="Decrease steps by one tact (4 steps)"
>
<MinusIcon className="w-5 h-5" />
</button>
<span
id="steps-display"
className="px-3 py-2 text-sm font-mono text-slate-700 w-16 text-center border-l border-r border-slate-300 bg-white"
>
{steps}
</span>
<button
onClick={incrementSteps}
disabled={steps >= MAX_STEPS}
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-r-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
aria-label="Increase steps by one tact (4 steps)"
>
<PlusIcon className="w-5 h-5" />
</button>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<SequencerButton onClick={handleImportClick} title="Import">
<UploadIcon className="w-5 h-5" /> <span className="hidden lg:inline">Import</span>
</SequencerButton>
<input type="file" ref={fileInputRef} onChange={importBeat} accept=".json" className="hidden" />
<SequencerButton onClick={exportBeat} title="Export">
<DownloadIcon className="w-5 h-5" /> <span className="hidden lg:inline">Export</span>
</SequencerButton>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex flex-col items-center gap-2 pt-10">
<input
id="drum-volume"
type="range" min="0" max="1.5" step="0.01" value={drumVolume}
onChange={e => handleDrumVolumeChange(Number(e.target.value))}
className="w-4 h-48 appearance-none cursor-pointer bg-slate-200 rounded-lg [writing-mode:vertical-lr] [direction:rtl] accent-orange-500"
aria-orientation="vertical"
/>
<label htmlFor="drum-volume" className="text-xs font-medium text-slate-500 tracking-wider">DRUMS</label>
</div>
<div className="flex-1 overflow-x-auto pb-2">
<div className="grid gap-1" style={{ gridTemplateColumns: `100px repeat(${steps}, 1fr)` }}>
{/* Header */}
<div className="sticky left-0 bg-white z-10"></div>
{Array.from({ length: steps }, (_, i) => (
<div key={`header-${i}`} className="text-center text-xs text-slate-400">
{(i % 4 === 0) ? (i/4 + 1) : ''}
</div>
))}
{/* Grid Rows */}
{INSTRUMENTS.map((instrument, instIndex) => (
<div className="contents group" key={instrument.name}>
<div className={`sticky left-0 bg-white flex items-center justify-end pr-2 py-1 transition-opacity z-10 ${mutes[instIndex] ? 'opacity-60' : ''}`}>
<button
onClick={() => toggleMute(instIndex)}
className="mr-1 p-1 rounded-full text-slate-400 hover:bg-slate-200 hover:text-slate-600 focus:ring-2 focus:ring-orange-500 transition-opacity"
aria-label={mutes[instIndex] ? `Unmute ${instrument.name}` : `Mute ${instrument.name}`}
>
{mutes[instIndex] ? <MuteIcon className="w-4 h-4" /> : <UnmuteIcon className="w-4 h-4 opacity-0 group-hover:opacity-100 focus:opacity-100" />}
</button>
<span className="text-xs sm:text-sm font-bold text-slate-600 tracking-wider text-right">{instrument.name}</span>
</div>
{grid[instIndex]?.map((isActive, stepIndex) => {
const isCurrent = currentStep === stepIndex;
const isFourth = stepIndex % 4 === 0;
return (
<div
key={`${instrument.name}-${stepIndex}`}
className={`w-full aspect-square rounded-md cursor-pointer transition-all duration-100 border ${isFourth ? 'border-l-slate-300' : 'border-l-transparent'} min-w-0
${isActive ? 'bg-orange-500 border-orange-600' : 'bg-slate-200 hover:bg-slate-300 border-slate-300'}
${isCurrent ? 'ring-2 ring-blue-500 ring-offset-1' : ''}
${mutes[instIndex] ? 'opacity-60' : ''}`}
onMouseDown={() => handleCellMouseDown(instIndex, stepIndex)}
onMouseEnter={() => handleCellMouseEnter(instIndex, stepIndex)}
onDragStart={(e) => e.preventDefault()}
/>
)
})}
</div>
))}
</div>
</div>
</div>
{/* Bass Sequencer */}
<div className="mt-8 pt-6 border-t border-slate-200 flex items-start gap-4">
<div className="flex flex-col items-center gap-2 pt-10">
<input
id="bass-volume"
type="range" min="0" max="1" step="0.01" value={bassVolume}
onChange={e => handleBassVolumeChange(Number(e.target.value))}
className="w-4 h-48 appearance-none cursor-pointer bg-slate-200 rounded-lg [writing-mode:vertical-lr] [direction:rtl] accent-purple-500"
aria-orientation="vertical"
/>
<label htmlFor="bass-volume" className="text-xs font-medium text-slate-500 tracking-wider">BASS</label>
</div>
<div className="flex-1 overflow-x-auto pb-2">
<div className="grid gap-px" style={{ gridTemplateColumns: `100px repeat(${steps}, 1fr)` }}>
{/* Header */}
<div className="sticky left-0 bg-white z-10"></div>
{Array.from({ length: steps }, (_, i) => (
<div key={`bass-header-${i}`} className="text-center text-xs text-slate-400 h-4 flex items-end justify-center">
{(i % 4 === 0) ? <span className="mb-1">{(i/4 + 1)}</span> : ''}
</div>
))}
{/* Bass Grid Rows (reversed to have low notes at the bottom) */}
{[...BASS_NOTES].reverse().map((note) => (
<div className="contents" key={note.name}>
<div className={`sticky left-0 bg-white flex items-center justify-end pr-2 py-0 z-10 ${note.isSharp ? 'bg-slate-100' : 'bg-white'}`}>
<span className={`text-xs font-mono tracking-wider text-right ${note.isSharp ? 'text-slate-500' : 'text-slate-700 font-bold'}`}>{note.name}</span>
</div>
{Array.from({ length: steps }).map((_, stepIndex) => {
const isSelected = bassLine[stepIndex]?.includes(note.name);
const isCurrent = currentStep === stepIndex;
return (
<div
key={`${note.name}-${stepIndex}`}
className={`w-full h-5 cursor-pointer transition-colors duration-100 relative
${isSelected ? 'bg-purple-500' : note.isSharp ? 'bg-slate-200 hover:bg-slate-300' : 'bg-slate-100 hover:bg-slate-300'}
`}
onMouseDown={() => handleBassCellMouseDown(note.name, stepIndex)}
onMouseEnter={() => handleBassCellMouseEnter(note.name, stepIndex)}
onDragStart={(e) => e.preventDefault()}
>
{isCurrent && <div className="absolute inset-0 bg-blue-500/30"></div>}
{stepIndex > 0 && stepIndex % 4 === 0 && <div className="absolute inset-y-0 left-0 w-px bg-slate-300"></div>}
</div>
)
})}
</div>
))}
</div>
</div>
</div>
</div>
</>
);
};