Files
KGStudio/src/components/track/TrackInfoItem.tsx
T

326 lines
12 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { useProjectStore } from '../../stores/projectStore';
import { TbPiano } from 'react-icons/tb';
import { TbSettings } from 'react-icons/tb';
import KGDropdown from '../common/KGDropdown';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { DEBUG_MODE } from '../../constants/uiConstants';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
interface TrackInfoItemProps {
track: KGTrack;
index: number;
isDragging: boolean;
isDragOver: boolean;
onTrackClick?: () => void;
onTrackNameEdit: (track: KGTrack, newName: string) => void; // eslint-disable-line no-unused-vars
onDragStart: (e: React.DragEvent<HTMLDivElement>, index: number) => void; // eslint-disable-line no-unused-vars
onDragOver: (e: React.DragEvent<HTMLDivElement>, index: number) => void; // eslint-disable-line no-unused-vars
onDrop: (e: React.DragEvent<HTMLDivElement>) => void; // eslint-disable-line no-unused-vars
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void; // eslint-disable-line no-unused-vars
}
const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
track,
index,
isDragging,
isDragOver,
onTrackClick,
onTrackNameEdit,
onDragStart,
onDragOver,
onDrop,
onDragEnd
}) => {
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, tracks: allTracks } = useProjectStore();
const isSelected = selectedTrackId === track.getId().toString();
// Inline instrument dropdown removed; use InstrumentSelection panel instead
// Initialize current instrument from track data
const getTrackInstrument = () => {
if (track instanceof KGMidiTrack) {
return track.getInstrument();
}
return 'acoustic_grand_piano'; // Default fallback
};
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
const settingsDropdownRef = useRef<HTMLDivElement>(null);
const suppressDragRef = useRef(false);
const [volume, setVolume] = useState(track.getVolume());
// Local flag to track slider interaction; not used for rendering
const isAdjustingVolumeRef = useRef(false);
const [muted, setMuted] = useState(false);
const [solo, setSolo] = useState(false);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
showSettingsDropdown &&
settingsDropdownRef.current &&
!settingsDropdownRef.current.contains(event.target as Node)
) {
setShowSettingsDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showSettingsDropdown]);
// Sync currentInstrument state with actual track instrument value
const instrumentFromTrack = track instanceof KGMidiTrack ? track.getInstrument() : 'acoustic_grand_piano';
useEffect(() => {
setCurrentInstrument(instrumentFromTrack);
}, [instrumentFromTrack]);
// Sync volume UI with model when tracks state changes (e.g., load, undo/redo, external updates)
useEffect(() => {
setVolume(track.getVolume());
}, [allTracks, track]);
// Sync mute/solo UI with audio interface state on track/project changes
useEffect(() => {
setMuted(false);
setSolo(false);
}, [allTracks, track]);
// Handle track name edit within the component
const handleTrackNameClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening piano roll when clicking track name
const newName = prompt("Enter track name:", track.getName());
if (newName) {
// Call the parent handler with the new name
onTrackNameEdit(track, newName);
}
};
// Prevent drag reordering when interacting with interactive controls
const handleMouseDownCapture = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const isInteractive = !!target.closest(
'input, button, .volume-slider, .instrument-dropdown, .settings-dropdown'
);
suppressDragRef.current = isInteractive;
};
const handleDragStartWrapper = (e: React.DragEvent<HTMLDivElement>) => {
if (suppressDragRef.current) {
e.preventDefault();
e.stopPropagation();
suppressDragRef.current = false;
return;
}
onDragStart(e, index);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
const next = Number(e.target.value) / 100;
isAdjustingVolumeRef.current = true;
setVolume(next);
try {
// Live preview: update audio only while sliding
KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next);
} catch (err) {
console.error('Failed to update live volume:', err);
}
};
const commitVolumeChange = () => {
// Only commit if value actually changed from model to avoid extra commands
const modelVolume = track.getVolume();
if (Math.abs(modelVolume - volume) < 1e-6) {
isAdjustingVolumeRef.current = false;
return;
}
try {
useProjectStore.getState().updateTrackProperties(track.getId(), { volume });
} catch (err) {
console.error('Failed to persist volume:', err);
} finally {
isAdjustingVolumeRef.current = false;
}
};
const handleResetVolume = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const defaultVolume = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
setVolume(defaultVolume);
try {
useProjectStore.getState().updateTrackProperties(track.getId(), { volume: defaultVolume });
} catch (err) {
console.error('Failed to reset volume:', err);
}
};
const handleToggleMute = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const next = !muted;
setMuted(next);
try {
KGAudioInterface.instance().setTrackMute(track.getId().toString(), next);
} catch (err) {
console.error('Failed to toggle mute:', err);
}
};
const handleToggleSolo = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const next = !solo;
setSolo(next);
try {
KGAudioInterface.instance().setTrackSolo(track.getId().toString(), next);
} catch (err) {
console.error('Failed to toggle solo:', err);
}
};
// Handle track click
const handleTrackClick = () => {
// Select this track when clicked
setSelectedTrack(track.getId().toString());
if (onTrackClick) {
onTrackClick();
}
};
// Inline instrument change removed; handled by InstrumentSelection panel
// Handle piano button click
const handlePianoButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
// Select this track as active when opening instrument panel
setSelectedTrack(track.getId().toString());
// Toggle global InstrumentSelection panel (it follows selectedTrackId)
toggleInstrumentSelectionForTrack();
};
// Handle settings button click
const handleSettingsButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
setShowSettingsDropdown(!showSettingsDropdown);
};
// Handle settings action
const handleSettingsAction = async (action: string) => {
if (action === 'Delete Track') {
const confirmed = window.confirm(`Are you sure you want to delete track "${track.getName()}"?`);
if (confirmed) {
try {
if (DEBUG_MODE.TRACK_INFO) {
console.log('Delete track confirmed for:', track.getName());
}
// Clear selection if this track is selected
// if (selectedTrackId === track.getId().toString()) {
// setSelectedTrack(null);
// }
// Delete the track using the command system
await removeTrack(track.getId());
// Close the settings dropdown
setShowSettingsDropdown(false);
} catch (error) {
console.error('Failed to delete track:', error);
alert('Failed to delete track. Please try again.');
}
}
}
};
return (
<div
className={`track-info ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
data-test-id={`track-info-${track.getId()}`}
onClick={handleTrackClick}
onMouseDownCapture={handleMouseDownCapture}
draggable={true}
onDragStart={handleDragStartWrapper}
onDragOver={(e) => onDragOver(e, index)}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<div className="track-controls">
<div className="track-name-and-volume">
<div className="instrument-image">
<img
src={`${import.meta.env.BASE_URL}resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`}
alt={String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.displayName || currentInstrument)}
width="64"
height="64"
/>
</div>
<div className="track-name-and-controls">
<div
className="track-name"
onClick={handleTrackNameClick}
>
{track.getName()}
</div>
<div className="volume-slider">
<input
type="range"
min="0"
max="100"
value={Math.round(volume * 100)}
onChange={handleVolumeChange}
onMouseDown={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }}
onTouchStart={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
onTouchEnd={(e) => { e.stopPropagation(); commitVolumeChange(); }}
onBlur={commitVolumeChange}
onClick={(e) => e.stopPropagation()}
/>
<button
className="reset-volume"
title="Reset volume"
aria-label="Reset volume"
onClick={handleResetVolume}
>
</button>
</div>
</div>
</div>
<div className="pan-controls">
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
<div>
<button className="instrument" onClick={handlePianoButtonClick}>
<TbPiano />
</button>
</div>
<div style={{ position: 'relative' }} ref={settingsDropdownRef}>
<button className="settings" onClick={handleSettingsButtonClick}>
<TbSettings />
</button>
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
<KGDropdown
options={['Delete Track']}
value={''}
onChange={handleSettingsAction}
label="Settings"
hideButton={true}
isOpen={showSettingsDropdown}
onToggle={setShowSettingsDropdown}
className="settings-dropdown"
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default TrackInfoItem;