feat: add multi-track audio import functionality
Implemented comprehensive multi-file audio import system: - Created ImportTrackDialog component with drag-and-drop and file browser support - Updated TrackList to integrate import functionality with dedicated buttons - Added multi-track-demo page to test and demonstrate import features - Sequential file processing with automatic track naming from filenames - Error handling for non-audio files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
127
app/multi-track-demo/page.tsx
Normal file
127
app/multi-track-demo/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { TrackList } from '@/components/tracks/TrackList';
|
||||||
|
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
|
||||||
|
import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Play, Pause, Square, SkipBack } from 'lucide-react';
|
||||||
|
import { formatDuration } from '@/lib/audio/decoder';
|
||||||
|
|
||||||
|
export default function MultiTrackDemoPage() {
|
||||||
|
const {
|
||||||
|
tracks,
|
||||||
|
addTrack,
|
||||||
|
addTrackFromBuffer,
|
||||||
|
removeTrack,
|
||||||
|
updateTrack,
|
||||||
|
clearTracks,
|
||||||
|
} = useMultiTrack();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
stop,
|
||||||
|
seek,
|
||||||
|
togglePlayPause,
|
||||||
|
} = useMultiTrackPlayer(tracks);
|
||||||
|
|
||||||
|
const [zoom, setZoom] = React.useState(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="h-14 border-b border-border bg-card flex items-center justify-between px-4">
|
||||||
|
<h1 className="text-lg font-semibold">Multi-Track Demo</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearTracks}
|
||||||
|
disabled={tracks.length === 0}
|
||||||
|
>
|
||||||
|
Clear All Tracks
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setZoom((z) => Math.max(0.5, z - 0.5))}
|
||||||
|
>
|
||||||
|
Zoom Out
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setZoom((z) => Math.min(10, z + 0.5))}
|
||||||
|
>
|
||||||
|
Zoom In
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track List */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<TrackList
|
||||||
|
tracks={tracks}
|
||||||
|
zoom={zoom}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
onAddTrack={() => addTrack()}
|
||||||
|
onImportTrack={(buffer, name) => addTrackFromBuffer(buffer, name)}
|
||||||
|
onRemoveTrack={removeTrack}
|
||||||
|
onUpdateTrack={updateTrack}
|
||||||
|
onSeek={seek}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<div className="h-16 border-t border-border bg-card flex items-center justify-between px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={stop}
|
||||||
|
disabled={!duration}
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
disabled={!duration}
|
||||||
|
title={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => seek(0)}
|
||||||
|
disabled={!duration}
|
||||||
|
title="Skip to Start"
|
||||||
|
>
|
||||||
|
<SkipBack className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{formatDuration(currentTime)} / {formatDuration(duration)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
components/tracks/ImportTrackDialog.tsx
Normal file
145
components/tracks/ImportTrackDialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Upload, Plus } from 'lucide-react';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { decodeAudioFile } from '@/lib/audio/decoder';
|
||||||
|
|
||||||
|
export interface ImportTrackDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onImportTrack: (buffer: AudioBuffer, name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportTrackDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onImportTrack,
|
||||||
|
}: ImportTrackDialogProps) {
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFiles = async (files: FileList) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process files sequentially
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
|
||||||
|
if (!file.type.startsWith('audio/')) {
|
||||||
|
console.warn(`Skipping non-audio file: ${file.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await decodeAudioFile(file);
|
||||||
|
const trackName = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||||
|
onImportTrack(buffer, trackName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to import ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleFiles(files);
|
||||||
|
}
|
||||||
|
// Reset input
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleFiles(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Import Audio as Tracks"
|
||||||
|
description="Import one or more audio files as new tracks"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Drag and Drop Area */}
|
||||||
|
<div
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`
|
||||||
|
border-2 border-dashed rounded-lg p-8 text-center transition-colors
|
||||||
|
${isDragging
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
}
|
||||||
|
${isLoading ? 'opacity-50 pointer-events-none' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Upload className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-foreground mb-2">
|
||||||
|
{isLoading ? 'Importing files...' : 'Drag and drop audio files here'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
or
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Choose Files
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supported Formats */}
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium mb-1">Supported formats:</p>
|
||||||
|
<p>MP3, WAV, OGG, FLAC, M4A, AAC, and more</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
💡 Tip: Select multiple files at once or drag multiple files to import them all as separate tracks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus, Upload } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Track } from './Track';
|
import { Track } from './Track';
|
||||||
|
import { ImportTrackDialog } from './ImportTrackDialog';
|
||||||
import type { Track as TrackType } from '@/types/track';
|
import type { Track as TrackType } from '@/types/track';
|
||||||
|
|
||||||
export interface TrackListProps {
|
export interface TrackListProps {
|
||||||
@@ -12,6 +13,7 @@ export interface TrackListProps {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
onAddTrack: () => void;
|
onAddTrack: () => void;
|
||||||
|
onImportTrack?: (buffer: AudioBuffer, name: string) => void;
|
||||||
onRemoveTrack: (trackId: string) => void;
|
onRemoveTrack: (trackId: string) => void;
|
||||||
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
|
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
@@ -23,19 +25,45 @@ export function TrackList({
|
|||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
onAddTrack,
|
onAddTrack,
|
||||||
|
onImportTrack,
|
||||||
onRemoveTrack,
|
onRemoveTrack,
|
||||||
onUpdateTrack,
|
onUpdateTrack,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: TrackListProps) {
|
}: TrackListProps) {
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
|
||||||
|
if (onImportTrack) {
|
||||||
|
onImportTrack(buffer, name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-muted-foreground">
|
<>
|
||||||
<p className="text-sm">No tracks yet. Add a track to get started.</p>
|
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||||
<Button onClick={onAddTrack} variant="secondary">
|
<p className="text-sm">No tracks yet. Add a track to get started.</p>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<div className="flex gap-2">
|
||||||
Add Track
|
<Button onClick={onAddTrack} variant="secondary">
|
||||||
</Button>
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</div>
|
Add Empty Track
|
||||||
|
</Button>
|
||||||
|
{onImportTrack && (
|
||||||
|
<Button onClick={() => setImportDialogOpen(true)} variant="secondary">
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Import Audio Files
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onImportTrack && (
|
||||||
|
<ImportTrackDialog
|
||||||
|
open={importDialogOpen}
|
||||||
|
onClose={() => setImportDialogOpen(false)}
|
||||||
|
onImportTrack={handleImportTrack}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +102,33 @@ export function TrackList({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Track Button */}
|
{/* Add Track Buttons */}
|
||||||
<div className="p-2 border-t border-border bg-card">
|
<div className="p-2 border-t border-border bg-card space-y-2">
|
||||||
<Button onClick={onAddTrack} variant="outline" size="sm" className="w-full">
|
<Button onClick={onAddTrack} variant="outline" size="sm" className="w-full">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Track
|
Add Empty Track
|
||||||
</Button>
|
</Button>
|
||||||
|
{onImportTrack && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setImportDialogOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Import Audio Files
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Import Dialog */}
|
||||||
|
{onImportTrack && (
|
||||||
|
<ImportTrackDialog
|
||||||
|
open={importDialogOpen}
|
||||||
|
onClose={() => setImportDialogOpen(false)}
|
||||||
|
onImportTrack={handleImportTrack}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user