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';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Plus, Upload } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Track } from './Track';
|
||||
import { ImportTrackDialog } from './ImportTrackDialog';
|
||||
import type { Track as TrackType } from '@/types/track';
|
||||
|
||||
export interface TrackListProps {
|
||||
@@ -12,6 +13,7 @@ export interface TrackListProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onAddTrack: () => void;
|
||||
onImportTrack?: (buffer: AudioBuffer, name: string) => void;
|
||||
onRemoveTrack: (trackId: string) => void;
|
||||
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
@@ -23,19 +25,45 @@ export function TrackList({
|
||||
currentTime,
|
||||
duration,
|
||||
onAddTrack,
|
||||
onImportTrack,
|
||||
onRemoveTrack,
|
||||
onUpdateTrack,
|
||||
onSeek,
|
||||
}: TrackListProps) {
|
||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||
|
||||
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
|
||||
if (onImportTrack) {
|
||||
onImportTrack(buffer, name);
|
||||
}
|
||||
};
|
||||
|
||||
if (tracks.length === 0) {
|
||||
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>
|
||||
<Button onClick={onAddTrack} variant="secondary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Track
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<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 gap-2">
|
||||
<Button onClick={onAddTrack} variant="secondary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
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>
|
||||
|
||||
{/* Add Track Button */}
|
||||
<div className="p-2 border-t border-border bg-card">
|
||||
{/* Add Track Buttons */}
|
||||
<div className="p-2 border-t border-border bg-card space-y-2">
|
||||
<Button onClick={onAddTrack} variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Track
|
||||
Add Empty Track
|
||||
</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>
|
||||
|
||||
{/* Import Dialog */}
|
||||
{onImportTrack && (
|
||||
<ImportTrackDialog
|
||||
open={importDialogOpen}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
onImportTrack={handleImportTrack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user