485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
import React, {
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
useCallback,
|
|
useRef,
|
|
} from 'react';
|
|
import Dialog from '@mui/material/Dialog';
|
|
import DialogTitle from '@mui/material/DialogTitle';
|
|
import DialogContent from '@mui/material/DialogContent';
|
|
import DialogActions from '@mui/material/DialogActions';
|
|
import Slider from '@mui/material/Slider';
|
|
import ToggleButton from '@mui/material/ToggleButton';
|
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
|
import { useWavesurfer } from '@wavesurfer/react';
|
|
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
|
|
import ZoomPlugin from 'wavesurfer.js/dist/plugins/zoom.esm.js';
|
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
import PauseIcon from '@mui/icons-material/Pause';
|
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
import { useSortable } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { ClipMetadata, PlaybackType } from '../../redux/types';
|
|
import { useAppSelector } from '../hooks';
|
|
import PlayStopIcon from './playStopIcon';
|
|
import PlayOverlapIcon from './playOverlapIcon';
|
|
|
|
export interface AudioTrimmerProps {
|
|
metadata: ClipMetadata;
|
|
onSave?: (metadata: ClipMetadata) => void;
|
|
onDelete?: (metadata: ClipMetadata) => void;
|
|
onMove?: (newCollection: string, metadata: ClipMetadata) => void;
|
|
}
|
|
|
|
export default function AudioTrimmer({
|
|
metadata,
|
|
onSave,
|
|
onDelete,
|
|
onMove,
|
|
}: AudioTrimmerProps) {
|
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
|
useSortable({ id: metadata.filename });
|
|
|
|
// Dialog state for editing name
|
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const [nameInput, setNameInput] = useState<string>(metadata.name);
|
|
const [volumeInput, setVolumeInput] = useState<number>(metadata.volume ?? 1);
|
|
const collectionNames = useAppSelector((state) =>
|
|
state.collections.map((col) => col.name),
|
|
);
|
|
|
|
useEffect(() => {
|
|
setNameInput(metadata.name);
|
|
}, [metadata.name]);
|
|
|
|
const openEditDialog = () => setEditDialogOpen(true);
|
|
const closeEditDialog = () => setEditDialogOpen(false);
|
|
|
|
const handleDialogSave = () => {
|
|
if (nameInput.trim() && nameInput !== metadata.name) {
|
|
const updated = { ...metadata, name: nameInput.trim() };
|
|
if (onSave) onSave(updated);
|
|
}
|
|
closeEditDialog();
|
|
};
|
|
|
|
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
|
const containerRef = useRef(null);
|
|
// const [clipStart, setClipStart] = useState<number | undefined>(undefined);
|
|
// const [clipEnd, setClipEnd] = useState<number | undefined>(undefined);
|
|
|
|
const plugins = useMemo(
|
|
() => [
|
|
RegionsPlugin.create(),
|
|
ZoomPlugin.create({
|
|
scale: 0.25,
|
|
}),
|
|
],
|
|
[],
|
|
);
|
|
|
|
const fileBaseName =
|
|
metadata.filename.split('\\').pop()?.split('/').pop() || 'Unknown';
|
|
|
|
const { wavesurfer, isReady, isPlaying } = useWavesurfer({
|
|
container: containerRef,
|
|
height: 100,
|
|
waveColor: '#ccb1ff',
|
|
progressColor: '#6e44ba',
|
|
hideScrollbar: true,
|
|
url: blobUrl,
|
|
plugins,
|
|
});
|
|
|
|
// Add this ref to always have the latest metadata
|
|
const metadataRef = useRef(metadata);
|
|
useEffect(() => {
|
|
metadataRef.current = metadata;
|
|
}, [metadata]);
|
|
|
|
const onRegionCreated = useCallback(
|
|
(newRegion: any) => {
|
|
if (wavesurfer === null) return;
|
|
|
|
const allRegions = (plugins[0] as RegionsPlugin).getRegions();
|
|
let isNew = metadataRef.current.startTime === undefined;
|
|
|
|
allRegions.forEach((region) => {
|
|
if (region.id !== newRegion.id) {
|
|
if (
|
|
region.start === newRegion.start &&
|
|
region.end === newRegion.end
|
|
) {
|
|
newRegion.remove();
|
|
return;
|
|
}
|
|
region.remove();
|
|
isNew = !(region.start === 0 && region.end === 0);
|
|
// console.log('Region replace:', newRegion, region);
|
|
}
|
|
});
|
|
|
|
if (isNew) {
|
|
console.log('Region created:', metadataRef.current);
|
|
const updated = {
|
|
...metadataRef.current,
|
|
startTime: newRegion.start,
|
|
endTime: newRegion.end,
|
|
};
|
|
if (onSave) {
|
|
onSave(updated);
|
|
}
|
|
}
|
|
},
|
|
[plugins, wavesurfer, onSave],
|
|
);
|
|
|
|
const onRegionUpdated = useCallback(
|
|
(newRegion: any) => {
|
|
if (wavesurfer === null) return;
|
|
|
|
const updated = {
|
|
...metadataRef.current,
|
|
startTime: newRegion.start,
|
|
endTime: newRegion.end,
|
|
};
|
|
if (onSave) {
|
|
onSave(updated);
|
|
}
|
|
},
|
|
[onSave, wavesurfer],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const plugin = plugins[0] as RegionsPlugin;
|
|
|
|
if (!isReady) return;
|
|
// console.log('ready, setting up regions plugin', plugin, isReady);
|
|
if (
|
|
metadataRef.current.startTime !== undefined &&
|
|
metadataRef.current.endTime !== undefined
|
|
) {
|
|
// setClipStart(metadata.startTime);
|
|
// setClipEnd(metadata.endTime);
|
|
// console.log('Adding region from metadata:', metadata);=
|
|
|
|
const allRegions = plugin.getRegions();
|
|
// console.log('Existing regions:', allRegions);
|
|
if (
|
|
allRegions.length === 0 ||
|
|
(allRegions.length === 1 &&
|
|
allRegions[0].start === 0 &&
|
|
allRegions[0].end === 0)
|
|
) {
|
|
// console.log('adding region from metadata:', metadataRef.current);
|
|
plugin.addRegion({
|
|
start: metadataRef.current.startTime,
|
|
end: metadataRef.current.endTime,
|
|
color: 'rgba(132, 81, 224, 0.3)',
|
|
drag: false,
|
|
resize: true,
|
|
});
|
|
}
|
|
} else {
|
|
// setClipStart(0);
|
|
// setClipEnd(wavesurfer ? wavesurfer.getDuration() : 0);
|
|
}
|
|
}, [isReady, plugins]);
|
|
|
|
useEffect(() => {
|
|
const plugin = plugins[0] as RegionsPlugin;
|
|
plugin.unAll();
|
|
plugin.on('region-created', onRegionCreated);
|
|
plugin.on('region-updated', onRegionUpdated);
|
|
plugin.enableDragSelection({
|
|
color: 'rgba(132, 81, 224, 0.3)',
|
|
});
|
|
}, [onRegionCreated, onRegionUpdated, plugins]);
|
|
|
|
useEffect(() => {
|
|
let url: string | null = null;
|
|
async function fetchAudio() {
|
|
// console.log('Loading audio buffer for file:', filename);
|
|
const buffer = await window.audio.loadAudioBuffer(metadata.filename);
|
|
// console.log('Received buffer:', buffer.buffer);
|
|
if (buffer.buffer && !buffer.error) {
|
|
const audioData = buffer.buffer
|
|
? new Uint8Array(buffer.buffer)
|
|
: buffer;
|
|
url = URL.createObjectURL(new Blob([audioData]));
|
|
// console.log('Created blob URL:', url);
|
|
setBlobUrl(url);
|
|
}
|
|
}
|
|
fetchAudio();
|
|
return () => {
|
|
if (url) URL.revokeObjectURL(url);
|
|
};
|
|
}, [metadata.filename]);
|
|
|
|
const onPlayPause = () => {
|
|
if (wavesurfer === null) return;
|
|
if (isPlaying) {
|
|
wavesurfer.pause();
|
|
} else {
|
|
const allRegions = (plugins[0] as RegionsPlugin).getRegions();
|
|
if (allRegions.length > 0) {
|
|
wavesurfer.setVolume(metadata.volume ?? 1);
|
|
wavesurfer.play(allRegions[0].start, allRegions[0].end);
|
|
} else {
|
|
wavesurfer.play();
|
|
}
|
|
}
|
|
};
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const secs = (seconds % 60).toFixed(0);
|
|
return `${minutes}:${secs.padStart(2, '0')}`;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
position: 'relative',
|
|
alignItems: 'stretch',
|
|
}}
|
|
className="shadow-[0_2px_8px_rgba(0,0,0,0.5)] m-2 rounded-lg bg-darkDrop"
|
|
>
|
|
<div
|
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
|
{...attributes}
|
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
|
{...listeners}
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: '10px',
|
|
borderRadius: '5px 0 0 5px',
|
|
cursor: 'grab',
|
|
}}
|
|
className="bg-neutral-800"
|
|
/>
|
|
{/* <div className="flex flex-col"> */}
|
|
<div className="ml-4 mr-2 p-2">
|
|
<div className="grid justify-items-stretch grid-cols-2">
|
|
<div className="mb-5px flex flex-col">
|
|
<span
|
|
className="font-bold text-lg text-white mb-1 cursor-pointer"
|
|
onClick={openEditDialog}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
openEditDialog();
|
|
}
|
|
}}
|
|
title="Click to edit name"
|
|
tabIndex={0}
|
|
role="button"
|
|
style={{ outline: 'none' }}
|
|
>
|
|
{metadata.name}
|
|
</span>
|
|
<span className="text-sm text-neutral-500">{fileBaseName}</span>
|
|
</div>
|
|
<Dialog
|
|
open={editDialogOpen}
|
|
onClose={closeEditDialog}
|
|
slotProps={{
|
|
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
|
|
}}
|
|
>
|
|
<DialogTitle>Edit Clip Name</DialogTitle>
|
|
<DialogContent>
|
|
<textarea
|
|
autoFocus
|
|
className="font-bold text-lg bg-transparent outline-none border-b border-plum focus:border-plumDark text-white mb-1 w-full text-center resize-y"
|
|
value={nameInput}
|
|
onChange={(e) => setNameInput(e.target.value)}
|
|
rows={3}
|
|
onFocus={(event) => event.target.select()}
|
|
aria-label="Edit clip name"
|
|
style={{ minHeight: '3em' }}
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<button
|
|
type="button"
|
|
onClick={closeEditDialog}
|
|
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDialogSave}
|
|
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
|
>
|
|
Save
|
|
</button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={deleteDialogOpen}
|
|
onClose={() => setDeleteDialogOpen(false)}
|
|
slotProps={{
|
|
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
|
|
}}
|
|
>
|
|
<DialogTitle>Confirm Delete</DialogTitle>
|
|
<DialogContent>
|
|
Are you sure you want to delete this clip?
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteDialogOpen(false)}
|
|
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDeleteDialogOpen(false);
|
|
if (onDelete) onDelete(metadataRef.current);
|
|
}}
|
|
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
|
>
|
|
Delete
|
|
</button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="button"
|
|
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
|
|
onClick={onPlayPause}
|
|
>
|
|
{isPlaying ? <PauseIcon /> : <PlayArrowIcon />}
|
|
</button>
|
|
<div className="relative inline-block">
|
|
<button
|
|
type="button"
|
|
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
|
|
onClick={() => setDropdownOpen((prev) => !prev)}
|
|
>
|
|
{dropdownOpen ? <ArrowDownwardIcon /> : <ArrowForwardIcon />}
|
|
</button>
|
|
{dropdownOpen && (
|
|
<div className="absolute z-10 mt-2 w-40 bg-midnight rounded-md shadow-lg">
|
|
{collectionNames.map((name) => (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
className="block w-full text-left px-4 py-2 text-white hover:bg-plumDark"
|
|
onClick={() => {
|
|
setDropdownOpen(false);
|
|
if (onMove) onMove(name, metadata);
|
|
}}
|
|
>
|
|
{name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
|
|
onClick={() => setDeleteDialogOpen(true)}
|
|
>
|
|
<DeleteIcon />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="m-1 wavesurfer-scroll-container">
|
|
<div ref={containerRef} className="wavesurfer-inner" />
|
|
</div>
|
|
<div className="flex justify-between mt-2">
|
|
<span className="w-1/5 flex-none text-sm text-neutral-500 self-center">
|
|
Clip: {formatTime(metadata.startTime ?? 0)} -{' '}
|
|
{formatTime(metadata.endTime ?? 0)}
|
|
</span>
|
|
<div className="w-3/5 flex-1 flex justify-center items-center">
|
|
<Slider
|
|
value={volumeInput}
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
onChange={(e, newValue) => setVolumeInput(newValue as number)}
|
|
onChangeCommitted={(e, newValue) => {
|
|
const newVolume = newValue as number;
|
|
console.log('Volume change:', newVolume);
|
|
if (onSave) onSave({ ...metadata, volume: newVolume });
|
|
}}
|
|
color="secondary"
|
|
className="p-0 m-0"
|
|
/>
|
|
{/* <input
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={volumeInput}
|
|
onChange={(e) => {
|
|
const newVolume = parseFloat(e.target.value);
|
|
setVolumeInput(newVolume);
|
|
}}
|
|
onDragEnd={(e) => {
|
|
console.log('Volume change:');
|
|
// const newVolume = parseFloat(e.target.value);
|
|
// if (onSave) onSave({ ...metadata, volume: newVolume });
|
|
}}
|
|
className="mx-2 w-full accent-plum"
|
|
aria-label="Volume slider"
|
|
/> */}
|
|
</div>
|
|
<div className="w-1/5 flex justify-end text-sm text-neutral-500">
|
|
<ToggleButtonGroup value={metadata.playbackType}>
|
|
<ToggleButton
|
|
value="playStop"
|
|
color="primary"
|
|
onClick={() => {
|
|
if (onSave)
|
|
onSave({
|
|
...metadata,
|
|
playbackType: PlaybackType.PlayStop,
|
|
});
|
|
}}
|
|
>
|
|
<PlayStopIcon />
|
|
</ToggleButton>
|
|
<ToggleButton
|
|
value="playOverlap"
|
|
color="primary"
|
|
onClick={() => {
|
|
if (onSave)
|
|
onSave({
|
|
...metadata,
|
|
playbackType: PlaybackType.PlayOverlap,
|
|
});
|
|
}}
|
|
>
|
|
<PlayOverlapIcon />
|
|
</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|