feat: enhance mobile responsiveness with collapsible controls and automation/effects bars

Added comprehensive mobile support for Phase 15 (Polish & Optimization):

**Mobile Layout Enhancements:**
- Track controls now collapsible on mobile with two states:
  - Collapsed: minimal controls with expand chevron, R/M/S buttons, horizontal level meter
  - Expanded: full height fader, pan control, all buttons
- Track collapse buttons added to mobile view (left chevron for track collapse, right chevron for control collapse)
- Master controls collapse button hidden on desktop (lg:hidden)
- Automation and effects bars now available on mobile layout
- Both bars collapsible with eye/eye-off icons, horizontally scrollable when zoomed
- Mobile vertical stacking: controls → waveform → automation → effects per track

**Bug Fixes:**
- Fixed track controls and waveform container height matching on desktop
- Fixed Modal component prop: isOpen → open in all dialog components
- Fixed TypeScript null check for audioBuffer.duration
- Fixed keyboard shortcut category: 'help' → 'view'

**Technical Improvements:**
- Consistent height calculation using trackHeight variable
- Proper responsive breakpoints with Tailwind (sm:640px, lg:1024px)
- Progressive disclosure pattern for mobile controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-19 20:50:44 +01:00
parent e09bc1449c
commit 908e6caaf8
13 changed files with 2136 additions and 126 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { CircularKnob } from '@/components/ui/CircularKnob';
import { MasterFader } from './MasterFader';
import { cn } from '@/lib/utils/cn';
@@ -12,10 +13,12 @@ export interface MasterControlsProps {
rmsLevel: number;
isClipping: boolean;
isMuted?: boolean;
collapsed?: boolean; // For collapsible on mobile/small screens
onVolumeChange: (volume: number) => void;
onPanChange: (pan: number) => void;
onMuteToggle: () => void;
onResetClip?: () => void;
onToggleCollapse?: () => void;
className?: string;
}
@@ -26,20 +29,81 @@ export function MasterControls({
rmsLevel,
isClipping,
isMuted = false,
collapsed = false,
onVolumeChange,
onPanChange,
onMuteToggle,
onResetClip,
onToggleCollapse,
className,
}: MasterControlsProps) {
// Collapsed view - minimal controls
if (collapsed) {
return (
<div className={cn(
'flex flex-col items-center gap-2 px-3 py-2 bg-card/50 border border-accent/50 rounded-lg w-full',
className
)}>
<div className="flex items-center justify-between w-full">
<div className="text-xs font-bold text-accent uppercase tracking-wider">
Master
</div>
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-1 hover:bg-accent/20 rounded transition-colors"
title="Expand master controls"
>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
<div className="flex items-center gap-2 w-full justify-center">
<button
onClick={onMuteToggle}
className={cn(
'h-7 w-7 rounded-md flex items-center justify-center transition-all text-xs font-bold',
isMuted
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title={isMuted ? 'Unmute' : 'Mute'}
>
M
</button>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all',
peakLevel > 0.95 ? 'bg-red-500' : peakLevel > 0.8 ? 'bg-yellow-500' : 'bg-green-500'
)}
style={{ width: `${peakLevel * 100}%` }}
/>
</div>
</div>
</div>
);
}
return (
<div className={cn(
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
className
)}>
{/* Master Label */}
<div className="text-[10px] font-bold text-accent uppercase tracking-wider">
Master
{/* Master Label with collapse button */}
<div className="flex items-center justify-between w-full">
<div className="text-[10px] font-bold text-accent uppercase tracking-wider flex-1 text-center">
Master
</div>
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0 lg:hidden"
title="Collapse master controls"
>
<ChevronUp className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
{/* Pan Control */}

View File

@@ -0,0 +1,130 @@
'use client';
import * as React from 'react';
import { AlertTriangle, XCircle, Info, X } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { getBrowserInfo } from '@/lib/utils/browser-compat';
interface BrowserCompatDialogProps {
open: boolean;
missingFeatures: string[];
warnings: string[];
onClose: () => void;
}
export function BrowserCompatDialog({
open,
missingFeatures,
warnings,
onClose,
}: BrowserCompatDialogProps) {
const [browserInfo, setBrowserInfo] = React.useState({ name: 'Unknown', version: 'Unknown' });
const hasErrors = missingFeatures.length > 0;
// Get browser info only on client side
React.useEffect(() => {
setBrowserInfo(getBrowserInfo());
}, []);
if (!open) return null;
return (
<Modal open={open} onClose={onClose} title="">
<div className="p-6 max-w-md">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
{hasErrors ? (
<>
<XCircle className="h-5 w-5 text-destructive" />
<h2 className="text-lg font-semibold">Browser Not Supported</h2>
</>
) : (
<>
<AlertTriangle className="h-5 w-5 text-yellow-500" />
<h2 className="text-lg font-semibold">Browser Warnings</h2>
</>
)}
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{hasErrors ? (
<>Your browser is missing required features to run this audio editor.</>
) : (
<>Some features may not work as expected in your browser.</>
)}
</p>
<div className="space-y-4">
{/* Browser Info */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Info className="h-4 w-4" />
<span>
{browserInfo.name} {browserInfo.version}
</span>
</div>
{/* Missing Features */}
{missingFeatures.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-destructive flex items-center gap-2">
<XCircle className="h-4 w-4" />
Missing Required Features:
</h3>
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
{missingFeatures.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-yellow-600 dark:text-yellow-500 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Warnings:
</h3>
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
{warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
{/* Recommendations */}
{hasErrors && (
<div className="bg-muted/50 border border-border rounded-md p-3 space-y-2">
<h3 className="text-sm font-semibold">Recommended Browsers:</h3>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Chrome 90+ or Edge 90+</li>
<li> Firefox 88+</li>
<li> Safari 14+</li>
</ul>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
{hasErrors ? (
<Button onClick={onClose} variant="destructive">
Close
</Button>
) : (
<Button onClick={onClose}>
Continue Anyway
</Button>
)}
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import * as React from 'react';
import { Keyboard, X } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn';
export interface KeyboardShortcutsDialogProps {
open: boolean;
onClose: () => void;
}
interface ShortcutCategory {
name: string;
shortcuts: Array<{
keys: string[];
description: string;
}>;
}
const SHORTCUTS: ShortcutCategory[] = [
{
name: 'Playback',
shortcuts: [
{ keys: ['Space'], description: 'Play / Pause' },
{ keys: ['Home'], description: 'Go to Start' },
{ keys: ['End'], description: 'Go to End' },
{ keys: ['←'], description: 'Seek Backward' },
{ keys: ['→'], description: 'Seek Forward' },
{ keys: ['Ctrl', '←'], description: 'Seek Backward 5s' },
{ keys: ['Ctrl', '→'], description: 'Seek Forward 5s' },
],
},
{
name: 'Edit',
shortcuts: [
{ keys: ['Ctrl', 'Z'], description: 'Undo' },
{ keys: ['Ctrl', 'Shift', 'Z'], description: 'Redo' },
{ keys: ['Ctrl', 'X'], description: 'Cut' },
{ keys: ['Ctrl', 'C'], description: 'Copy' },
{ keys: ['Ctrl', 'V'], description: 'Paste' },
{ keys: ['Delete'], description: 'Delete Selection' },
{ keys: ['Ctrl', 'D'], description: 'Duplicate' },
{ keys: ['Ctrl', 'A'], description: 'Select All' },
],
},
{
name: 'View',
shortcuts: [
{ keys: ['Ctrl', '+'], description: 'Zoom In' },
{ keys: ['Ctrl', '-'], description: 'Zoom Out' },
{ keys: ['Ctrl', '0'], description: 'Fit to View' },
],
},
{
name: 'File',
shortcuts: [
{ keys: ['Ctrl', 'S'], description: 'Save Project' },
{ keys: ['Ctrl', 'K'], description: 'Open Command Palette' },
],
},
];
function KeyboardKey({ keyName }: { keyName: string }) {
return (
<kbd className="px-2 py-1 text-xs font-semibold bg-muted border border-border rounded shadow-sm min-w-[2rem] text-center inline-block">
{keyName}
</kbd>
);
}
export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDialogProps) {
if (!open) return null;
return (
<Modal open={open} onClose={onClose} title="">
<div className="p-6 max-w-2xl">
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-3">
<Keyboard className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold">Keyboard Shortcuts</h2>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="h-5 w-5" />
</button>
</div>
{/* Shortcuts Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{SHORTCUTS.map((category) => (
<div key={category.name} className="space-y-3">
<h3 className="text-sm font-semibold text-primary border-b border-border pb-2">
{category.name}
</h3>
<div className="space-y-2">
{category.shortcuts.map((shortcut, index) => (
<div
key={index}
className="flex items-center justify-between gap-4 py-1.5"
>
<span className="text-sm text-foreground flex-1">
{shortcut.description}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{shortcut.keys.map((key, keyIndex) => (
<React.Fragment key={keyIndex}>
{keyIndex > 0 && (
<span className="text-muted-foreground text-xs mx-0.5">+</span>
)}
<KeyboardKey keyName={key} />
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="mt-6 pt-4 border-t border-border">
<p className="text-xs text-muted-foreground text-center">
Press <KeyboardKey keyName="Ctrl" /> + <KeyboardKey keyName="K" /> to open the
command palette and search for more actions
</p>
</div>
{/* Close Button */}
<div className="mt-6 flex justify-end">
<Button onClick={onClose} variant="default">
Close
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import * as React from 'react';
import { AlertTriangle, Info, X } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { formatMemorySize } from '@/lib/utils/memory-limits';
interface MemoryWarningDialogProps {
open: boolean;
estimatedMemoryMB: number;
availableMemoryMB?: number;
warning: string;
fileName?: string;
onContinue: () => void;
onCancel: () => void;
}
export function MemoryWarningDialog({
open,
estimatedMemoryMB,
availableMemoryMB,
warning,
fileName,
onContinue,
onCancel,
}: MemoryWarningDialogProps) {
if (!open) return null;
const estimatedBytes = estimatedMemoryMB * 1024 * 1024;
const availableBytes = availableMemoryMB ? availableMemoryMB * 1024 * 1024 : undefined;
return (
<Modal open={open} onClose={onCancel} title="">
<div className="p-6 max-w-md">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
<h2 className="text-lg font-semibold">Memory Warning</h2>
</div>
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{warning}
</p>
<div className="space-y-4">
{/* File Info */}
{fileName && (
<div className="flex items-center gap-2 text-sm">
<Info className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">File:</span>
<span className="text-muted-foreground truncate">{fileName}</span>
</div>
)}
{/* Memory Details */}
<div className="bg-muted/50 border border-border rounded-md p-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Estimated Memory:</span>
<span className="font-medium">{formatMemorySize(estimatedBytes)}</span>
</div>
{availableBytes && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Available Memory:</span>
<span className="font-medium">{formatMemorySize(availableBytes)}</span>
</div>
)}
</div>
{/* Warning Message */}
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-md p-3">
<p className="text-sm text-yellow-700 dark:text-yellow-400">
<strong>Note:</strong> Loading large files may cause performance issues or browser crashes,
especially on devices with limited memory. Consider:
</p>
<ul className="mt-2 text-sm text-yellow-700 dark:text-yellow-400 space-y-1 list-disc list-inside">
<li>Closing other browser tabs</li>
<li>Using a shorter audio file</li>
<li>Splitting large files into smaller segments</li>
</ul>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button onClick={onContinue} variant="default">
Continue Anyway
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import * as React from 'react';
import { AlertCircle, FileQuestion, X } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
export interface UnsupportedFormatDialogProps {
open: boolean;
fileName: string;
fileType: string;
onClose: () => void;
}
const SUPPORTED_FORMATS = [
{ extension: 'WAV', mimeType: 'audio/wav', description: 'Lossless, widely supported' },
{ extension: 'MP3', mimeType: 'audio/mpeg', description: 'Compressed, universal support' },
{ extension: 'OGG', mimeType: 'audio/ogg', description: 'Free, open format' },
{ extension: 'FLAC', mimeType: 'audio/flac', description: 'Lossless compression' },
{ extension: 'M4A/AAC', mimeType: 'audio/aac', description: 'Apple audio format' },
{ extension: 'AIFF', mimeType: 'audio/aiff', description: 'Apple lossless format' },
{ extension: 'WebM', mimeType: 'audio/webm', description: 'Modern web format' },
];
export function UnsupportedFormatDialog({
open,
fileName,
fileType,
onClose,
}: UnsupportedFormatDialogProps) {
if (!open) return null;
return (
<Modal open={open} onClose={onClose} title="">
<div className="p-6 max-w-lg">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<FileQuestion className="h-6 w-6 text-yellow-500" />
<h2 className="text-lg font-semibold">Unsupported File Format</h2>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
{/* Error Message */}
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-md p-4 mb-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">
Cannot open this file
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300">
<strong>{fileName}</strong>
{fileType && (
<span className="text-muted-foreground"> ({fileType})</span>
)}
</p>
</div>
</div>
</div>
{/* Supported Formats */}
<div className="mb-6">
<h3 className="text-sm font-semibold mb-3">Supported Audio Formats:</h3>
<div className="grid grid-cols-1 gap-2">
{SUPPORTED_FORMATS.map((format) => (
<div
key={format.extension}
className="flex items-center justify-between gap-4 p-2 rounded bg-muted/30 border border-border/50"
>
<div className="flex items-center gap-3">
<span className="text-sm font-mono font-semibold text-primary min-w-[80px]">
{format.extension}
</span>
<span className="text-xs text-muted-foreground">
{format.description}
</span>
</div>
</div>
))}
</div>
</div>
{/* Recommendations */}
<div className="bg-muted/50 border border-border rounded-md p-4 mb-4">
<h4 className="text-sm font-semibold mb-2">How to fix this:</h4>
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
<li>Convert your audio file to a supported format (WAV or MP3 recommended)</li>
<li>Use a free audio converter like Audacity, FFmpeg, or online converters</li>
<li>Check that the file isn't corrupted or incomplete</li>
</ul>
</div>
{/* Close Button */}
<div className="flex justify-end">
<Button onClick={onClose} variant="default">
Got it
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -4,16 +4,9 @@ import * as React from 'react';
import { Music, Plus, Upload, Trash2, Settings, Download, FolderOpen } from 'lucide-react';
import { PlaybackControls } from './PlaybackControls';
import { MasterControls } from '@/components/controls/MasterControls';
import { FrequencyAnalyzer } from '@/components/analysis/FrequencyAnalyzer';
import { Spectrogram } from '@/components/analysis/Spectrogram';
import { PhaseCorrelationMeter } from '@/components/analysis/PhaseCorrelationMeter';
import { LUFSMeter } from '@/components/analysis/LUFSMeter';
import { AudioStatistics } from '@/components/analysis/AudioStatistics';
import { ThemeToggle } from '@/components/layout/ThemeToggle';
import { CommandPalette } from '@/components/ui/CommandPalette';
import { GlobalSettingsDialog } from '@/components/settings/GlobalSettingsDialog';
import { ExportDialog, type ExportSettings } from '@/components/dialogs/ExportDialog';
import { ProjectsDialog } from '@/components/dialogs/ProjectsDialog';
import { BrowserCompatDialog } from '@/components/dialogs/BrowserCompatDialog';
import { Button } from '@/components/ui/Button';
import type { CommandAction } from '@/components/ui/CommandPalette';
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
@@ -21,7 +14,21 @@ import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer';
import { useEffectChain } from '@/lib/hooks/useEffectChain';
import { useToast } from '@/components/ui/Toast';
import { TrackList } from '@/components/tracks/TrackList';
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
import type { ExportSettings } from '@/components/dialogs/ExportDialog';
// Lazy load dialogs for better performance
const GlobalSettingsDialog = React.lazy(() => import('@/components/settings/GlobalSettingsDialog').then(m => ({ default: m.GlobalSettingsDialog })));
const ExportDialog = React.lazy(() => import('@/components/dialogs/ExportDialog').then(m => ({ default: m.ExportDialog })));
const ProjectsDialog = React.lazy(() => import('@/components/dialogs/ProjectsDialog').then(m => ({ default: m.ProjectsDialog })));
const ImportTrackDialog = React.lazy(() => import('@/components/tracks/ImportTrackDialog').then(m => ({ default: m.ImportTrackDialog })));
const KeyboardShortcutsDialog = React.lazy(() => import('@/components/dialogs/KeyboardShortcutsDialog').then(m => ({ default: m.KeyboardShortcutsDialog })));
// Lazy load analysis components (shown conditionally based on analyzerView)
const FrequencyAnalyzer = React.lazy(() => import('@/components/analysis/FrequencyAnalyzer').then(m => ({ default: m.FrequencyAnalyzer })));
const Spectrogram = React.lazy(() => import('@/components/analysis/Spectrogram').then(m => ({ default: m.Spectrogram })));
const PhaseCorrelationMeter = React.lazy(() => import('@/components/analysis/PhaseCorrelationMeter').then(m => ({ default: m.PhaseCorrelationMeter })));
const LUFSMeter = React.lazy(() => import('@/components/analysis/LUFSMeter').then(m => ({ default: m.LUFSMeter })));
const AudioStatistics = React.lazy(() => import('@/components/analysis/AudioStatistics').then(m => ({ default: m.AudioStatistics })));
import { formatDuration } from '@/lib/audio/decoder';
import { useHistory } from '@/lib/hooks/useHistory';
import { useRecording } from '@/lib/hooks/useRecording';
@@ -48,6 +55,7 @@ import {
type ProjectMetadata,
} from '@/lib/storage/projects';
import { getAudioContext } from '@/lib/audio/context';
import { checkBrowserCompatibility } from '@/lib/utils/browser-compat';
export function AudioEditor() {
// Settings hook
@@ -66,6 +74,7 @@ export function AudioEditor() {
const [masterVolume, setMasterVolume] = React.useState(0.8);
const [masterPan, setMasterPan] = React.useState(0);
const [isMasterMuted, setIsMasterMuted] = React.useState(false);
const [masterControlsCollapsed, setMasterControlsCollapsed] = React.useState(false);
const [clipboard, setClipboard] = React.useState<AudioBuffer | null>(null);
const [recordingTrackId, setRecordingTrackId] = React.useState<string | null>(null);
const [punchInEnabled, setPunchInEnabled] = React.useState(false);
@@ -87,6 +96,12 @@ export function AudioEditor() {
const [projects, setProjects] = React.useState<ProjectMetadata[]>([]);
const [currentProjectId, setCurrentProjectId] = React.useState<string | null>(null);
const [currentProjectName, setCurrentProjectName] = React.useState('Untitled Project');
const [browserCompatDialogOpen, setBrowserCompatDialogOpen] = React.useState(false);
const [shortcutsDialogOpen, setShortcutsDialogOpen] = React.useState(false);
const [browserCompatInfo, setBrowserCompatInfo] = React.useState<{
missingFeatures: string[];
warnings: string[];
}>({ missingFeatures: [], warnings: [] });
const { addToast } = useToast();
@@ -633,6 +648,9 @@ export function AudioEditor() {
);
setClipboard(extracted);
// Ensure the track is selected so paste works
setSelectedTrackId(track.id);
// Execute cut command
const command = createMultiTrackCutCommand(
track.id,
@@ -664,6 +682,9 @@ export function AudioEditor() {
);
setClipboard(extracted);
// Ensure the track is selected so paste works
setSelectedTrackId(track.id);
// Execute copy command (doesn't modify buffer, just for undo history)
const command = createMultiTrackCopyCommand(
track.id,
@@ -684,7 +705,25 @@ export function AudioEditor() {
}, [tracks, executeCommand, updateTrack, addToast]);
const handlePaste = React.useCallback(() => {
if (!clipboard || !selectedTrackId) return;
if (!clipboard) {
addToast({
title: 'Nothing to Paste',
description: 'Clipboard is empty. Copy or cut a selection first.',
variant: 'error',
duration: 2000,
});
return;
}
if (!selectedTrackId) {
addToast({
title: 'No Track Selected',
description: 'Select a track to paste into.',
variant: 'error',
duration: 2000,
});
return;
}
const track = tracks.find((t) => t.id === selectedTrackId);
if (!track) return;
@@ -1056,6 +1095,16 @@ export function AudioEditor() {
}
}, [loadTracks, addToast]);
// Check browser compatibility on mount
React.useEffect(() => {
const { isSupported, missingFeatures, warnings } = checkBrowserCompatibility();
if (!isSupported || warnings.length > 0) {
setBrowserCompatInfo({ missingFeatures, warnings });
setBrowserCompatDialogOpen(true);
}
}, []);
// Auto-load last project on mount
const [hasAutoLoaded, setHasAutoLoaded] = React.useState(false);
React.useEffect(() => {
@@ -1266,6 +1315,80 @@ export function AudioEditor() {
category: 'playback',
action: stop,
},
// Edit
{
id: 'undo',
label: 'Undo',
description: 'Undo last action',
shortcut: 'Ctrl+Z',
category: 'edit',
action: undo,
},
{
id: 'redo',
label: 'Redo',
description: 'Redo last undone action',
shortcut: 'Ctrl+Shift+Z',
category: 'edit',
action: redo,
},
{
id: 'cut',
label: 'Cut',
description: 'Cut selection to clipboard',
shortcut: 'Ctrl+X',
category: 'edit',
action: handleCut,
},
{
id: 'copy',
label: 'Copy',
description: 'Copy selection to clipboard',
shortcut: 'Ctrl+C',
category: 'edit',
action: handleCopy,
},
{
id: 'paste',
label: 'Paste',
description: 'Paste clipboard content',
shortcut: 'Ctrl+V',
category: 'edit',
action: handlePaste,
},
{
id: 'delete',
label: 'Delete',
description: 'Delete selection',
shortcut: 'Delete',
category: 'edit',
action: handleDelete,
},
{
id: 'duplicate',
label: 'Duplicate',
description: 'Duplicate selection',
shortcut: 'Ctrl+D',
category: 'edit',
action: handleDuplicate,
},
{
id: 'select-all',
label: 'Select All',
description: 'Select all content on current track',
shortcut: 'Ctrl+A',
category: 'edit',
action: () => {
if (selectedTrackId) {
const track = tracks.find(t => t.id === selectedTrackId);
if (track?.audioBuffer) {
updateTrack(selectedTrackId, {
selection: { start: 0, end: track.audioBuffer.duration }
});
}
}
},
},
// Project
{
id: 'save-project',
@@ -1287,6 +1410,7 @@ export function AudioEditor() {
id: 'zoom-in',
label: 'Zoom In',
description: 'Zoom in on waveforms',
shortcut: 'Ctrl++',
category: 'view',
action: handleZoomIn,
},
@@ -1294,6 +1418,7 @@ export function AudioEditor() {
id: 'zoom-out',
label: 'Zoom Out',
description: 'Zoom out on waveforms',
shortcut: 'Ctrl+-',
category: 'view',
action: handleZoomOut,
},
@@ -1301,9 +1426,59 @@ export function AudioEditor() {
id: 'fit-to-view',
label: 'Fit to View',
description: 'Reset zoom to fit all tracks',
shortcut: 'Ctrl+0',
category: 'view',
action: handleFitToView,
},
// Navigation
{
id: 'seek-start',
label: 'Go to Start',
description: 'Seek to beginning',
shortcut: 'Home',
category: 'playback',
action: () => seek(0),
},
{
id: 'seek-end',
label: 'Go to End',
description: 'Seek to end',
shortcut: 'End',
category: 'playback',
action: () => seek(duration),
},
{
id: 'seek-backward',
label: 'Seek Backward',
description: 'Seek backward 1 second',
shortcut: '←',
category: 'playback',
action: () => seek(Math.max(0, currentTime - 1)),
},
{
id: 'seek-forward',
label: 'Seek Forward',
description: 'Seek forward 1 second',
shortcut: '→',
category: 'playback',
action: () => seek(Math.min(duration, currentTime + 1)),
},
{
id: 'seek-backward-5s',
label: 'Seek Backward 5s',
description: 'Seek backward 5 seconds',
shortcut: 'Ctrl+←',
category: 'playback',
action: () => seek(Math.max(0, currentTime - 5)),
},
{
id: 'seek-forward-5s',
label: 'Seek Forward 5s',
description: 'Seek forward 5 seconds',
shortcut: 'Ctrl+→',
category: 'playback',
action: () => seek(Math.min(duration, currentTime + 5)),
},
// Tracks
{
id: 'add-track',
@@ -1326,9 +1501,18 @@ export function AudioEditor() {
category: 'tracks',
action: handleClearTracks,
},
// Help
{
id: 'keyboard-shortcuts',
label: 'Keyboard Shortcuts',
description: 'View all keyboard shortcuts',
shortcut: '?',
category: 'view',
action: () => setShortcutsDialogOpen(true),
},
];
return actions;
}, [play, pause, stop, handleSaveProject, handleOpenProjectsDialog, handleZoomIn, handleZoomOut, handleFitToView, handleImportTracks, handleClearTracks, addTrack]);
}, [play, pause, stop, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate, handleSaveProject, handleOpenProjectsDialog, handleZoomIn, handleZoomOut, handleFitToView, handleImportTracks, handleClearTracks, addTrack, seek, duration, currentTime, selectedTrackId, tracks, updateTrack, setShortcutsDialogOpen]);
// Keyboard shortcuts
React.useEffect(() => {
@@ -1398,6 +1582,27 @@ export function AudioEditor() {
return;
}
// Ctrl/Cmd++: Zoom In (also accepts Ctrl+=)
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
e.preventDefault();
handleZoomIn();
return;
}
// Ctrl/Cmd+-: Zoom Out
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
e.preventDefault();
handleZoomOut();
return;
}
// Ctrl/Cmd+0: Fit to View
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
e.preventDefault();
handleFitToView();
return;
}
// Delete or Backspace: Delete selection
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
@@ -1457,7 +1662,16 @@ export function AudioEditor() {
// Ctrl+A: Select All (select all content on current track)
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
if (selectedTrackId) {
if (!selectedTrackId) {
// If no track selected, try to select the first track with audio
const trackWithAudio = tracks.find(t => t.audioBuffer);
if (trackWithAudio && trackWithAudio.audioBuffer) {
setSelectedTrackId(trackWithAudio.id);
updateTrack(trackWithAudio.id, {
selection: { start: 0, end: trackWithAudio.audioBuffer.duration }
});
}
} else {
const track = tracks.find(t => t.id === selectedTrackId);
if (track?.audioBuffer) {
updateTrack(selectedTrackId, {
@@ -1468,45 +1682,31 @@ export function AudioEditor() {
return;
}
// Ctrl+Plus/Equals: Zoom in
if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) {
// ?: Open keyboard shortcuts help
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleZoomIn();
return;
}
// Ctrl+Minus: Zoom out
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
e.preventDefault();
handleZoomOut();
return;
}
// Ctrl+0: Fit to view
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
e.preventDefault();
handleFitToView();
setShortcutsDialogOpen(true);
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [togglePlayPause, canUndo, canRedo, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate, handleSaveProject, seek, duration, currentTime, selectedTrackId, tracks, updateTrack, handleZoomIn, handleZoomOut, handleFitToView]);
}, [togglePlayPause, canUndo, canRedo, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate, handleSaveProject, seek, duration, currentTime, selectedTrackId, tracks, updateTrack, handleZoomIn, handleZoomOut, handleFitToView, setSelectedTrackId, setShortcutsDialogOpen]);
return (
<>
{/* Compact Header */}
<header className="flex items-center justify-between px-4 py-2 border-b border-border bg-card flex-shrink-0 gap-4">
<header className="flex items-center justify-between px-2 sm:px-4 py-2 border-b border-border bg-card flex-shrink-0 gap-2 sm:gap-4">
{/* Left: Logo */}
<div className="flex items-center gap-4 flex-shrink-0">
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
<div className="flex items-center gap-2">
<Music className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold text-foreground">Audio UI</h1>
<h1 className="text-base sm:text-lg font-bold text-foreground hidden sm:block">Audio UI</h1>
</div>
{/* Project Name */}
<div className="flex items-center gap-2 border-l border-border pl-4">
<div className="hidden md:flex items-center gap-2 border-l border-border pl-4">
<input
type="text"
value={currentProjectName}
@@ -1518,27 +1718,31 @@ export function AudioEditor() {
/>
</div>
{/* Track Actions */}
<div className="flex items-center gap-2 border-l border-border pl-4">
<Button variant="outline" size="sm" onClick={handleOpenProjectsDialog}>
{/* Track Actions - Compact on mobile */}
<div className="flex items-center gap-1 sm:gap-2 border-l border-border pl-2 sm:pl-4">
<Button variant="outline" size="sm" onClick={handleOpenProjectsDialog} className="hidden sm:flex">
<FolderOpen className="h-4 w-4 mr-1.5" />
Projects
</Button>
<Button variant="outline" size="sm" onClick={handleOpenProjectsDialog} className="sm:hidden px-2">
<FolderOpen className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => addTrack()}>
<Plus className="h-4 w-4 mr-1.5" />
Add Track
<Plus className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">Add Track</span>
</Button>
<Button variant="outline" size="sm" onClick={handleImportTracks}>
<Upload className="h-4 w-4 mr-1.5" />
Import
<Upload className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">Import</span>
</Button>
{tracks.length > 0 && (
<>
<Button variant="outline" size="sm" onClick={() => setExportDialogOpen(true)}>
<Button variant="outline" size="sm" onClick={() => setExportDialogOpen(true)} className="hidden md:flex">
<Download className="h-4 w-4 mr-1.5" />
Export
</Button>
<Button variant="outline" size="sm" onClick={handleClearTracks}>
<Button variant="outline" size="sm" onClick={handleClearTracks} className="hidden lg:flex">
<Trash2 className="h-4 w-4 mr-1.5 text-destructive" />
Clear All
</Button>
@@ -1591,8 +1795,8 @@ export function AudioEditor() {
</div>
</main>
{/* Right Sidebar - Master Controls & Analyzers */}
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col pt-5 px-4 pb-4 gap-4 w-60">
{/* Right Sidebar - Master Controls & Analyzers - Hidden on mobile */}
<aside className="hidden lg:flex flex-shrink-0 border-l border-border bg-card flex-col pt-5 px-4 pb-4 gap-4 w-60">
{/* Master Controls */}
<div className="flex items-center justify-center">
<MasterControls
@@ -1602,6 +1806,7 @@ export function AudioEditor() {
rmsLevel={masterRmsLevel}
isClipping={masterIsClipping}
isMuted={isMasterMuted}
collapsed={masterControlsCollapsed}
onVolumeChange={setMasterVolume}
onPanChange={setMasterPan}
onMuteToggle={() => {
@@ -1614,6 +1819,7 @@ export function AudioEditor() {
}
}}
onResetClip={resetClipIndicator}
onToggleCollapse={() => setMasterControlsCollapsed(!masterControlsCollapsed)}
/>
</div>
@@ -1681,89 +1887,162 @@ export function AudioEditor() {
{/* Analyzer Display */}
<div className="flex-1 min-h-[360px] flex items-start justify-center">
<div className="w-[178px]">
{analyzerView === 'frequency' && <FrequencyAnalyzer analyserNode={masterAnalyser} />}
{analyzerView === 'spectrogram' && settings.performance.enableSpectrogram && <Spectrogram analyserNode={masterAnalyser} />}
{analyzerView === 'phase' && <PhaseCorrelationMeter analyserNode={masterAnalyser} />}
{analyzerView === 'lufs' && <LUFSMeter analyserNode={masterAnalyser} />}
{analyzerView === 'stats' && <AudioStatistics tracks={tracks} />}
{analyzerView === 'frequency' && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<FrequencyAnalyzer analyserNode={masterAnalyser} />
</React.Suspense>
)}
{analyzerView === 'spectrogram' && settings.performance.enableSpectrogram && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<Spectrogram analyserNode={masterAnalyser} />
</React.Suspense>
)}
{analyzerView === 'phase' && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<PhaseCorrelationMeter analyserNode={masterAnalyser} />
</React.Suspense>
)}
{analyzerView === 'lufs' && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<LUFSMeter analyserNode={masterAnalyser} />
</React.Suspense>
)}
{analyzerView === 'stats' && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<AudioStatistics tracks={tracks} />
</React.Suspense>
)}
</div>
</div>
</aside>
</div>
{/* Transport Controls */}
<div className="border-t border-border bg-card p-3 flex justify-center">
<PlaybackControls
isPlaying={isPlaying}
isPaused={!isPlaying}
currentTime={currentTime}
duration={duration}
volume={masterVolume}
onPlay={play}
onPause={pause}
onStop={stop}
onSeek={seek}
onVolumeChange={setMasterVolume}
currentTimeFormatted={formatDuration(currentTime)}
durationFormatted={formatDuration(duration)}
isRecording={recordingState.isRecording}
onStartRecording={handleStartRecording}
onStopRecording={handleStopRecording}
punchInEnabled={punchInEnabled}
punchInTime={punchInTime}
punchOutTime={punchOutTime}
onPunchInEnabledChange={setPunchInEnabled}
onPunchInTimeChange={setPunchInTime}
onPunchOutTimeChange={setPunchOutTime}
overdubEnabled={overdubEnabled}
onOverdubEnabledChange={setOverdubEnabled}
/>
{/* Bottom Bar - Stacked on mobile (Master then Transport), Side-by-side on desktop */}
<div className="border-t border-border bg-card p-3 flex flex-col lg:flex-row gap-3">
{/* Master Controls - Mobile only (hidden on desktop where sidebar shows master) */}
<div className="lg:hidden flex justify-center border-b border-border pb-3 lg:border-b-0 lg:pb-0">
<MasterControls
volume={masterVolume}
pan={masterPan}
peakLevel={masterPeakLevel}
rmsLevel={masterRmsLevel}
isClipping={masterIsClipping}
isMuted={isMasterMuted}
collapsed={masterControlsCollapsed}
onVolumeChange={setMasterVolume}
onPanChange={setMasterPan}
onMuteToggle={() => {
if (isMasterMuted) {
setMasterVolume(0.8);
setIsMasterMuted(false);
} else {
setMasterVolume(0);
setIsMasterMuted(true);
}
}}
onResetClip={resetClipIndicator}
onToggleCollapse={() => setMasterControlsCollapsed(!masterControlsCollapsed)}
/>
</div>
{/* Playback Controls - Bottom on mobile, centered on desktop */}
<div className="flex-1 flex justify-center">
<PlaybackControls
isPlaying={isPlaying}
isPaused={!isPlaying}
currentTime={currentTime}
duration={duration}
volume={masterVolume}
onPlay={play}
onPause={pause}
onStop={stop}
onSeek={seek}
onVolumeChange={setMasterVolume}
currentTimeFormatted={formatDuration(currentTime)}
durationFormatted={formatDuration(duration)}
isRecording={recordingState.isRecording}
onStartRecording={handleStartRecording}
onStopRecording={handleStopRecording}
punchInEnabled={punchInEnabled}
punchInTime={punchInTime}
punchOutTime={punchOutTime}
onPunchInEnabledChange={setPunchInEnabled}
onPunchInTimeChange={setPunchInTime}
onPunchOutTimeChange={setPunchOutTime}
overdubEnabled={overdubEnabled}
onOverdubEnabledChange={setOverdubEnabled}
/>
</div>
</div>
{/* Import Track Dialog */}
<ImportTrackDialog
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImportTrack={handleImportTrack}
/>
<React.Suspense fallback={null}>
<ImportTrackDialog
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImportTrack={handleImportTrack}
/>
</React.Suspense>
{/* Global Settings Dialog */}
<GlobalSettingsDialog
open={settingsDialogOpen}
onClose={() => setSettingsDialogOpen(false)}
recordingSettings={recordingSettings}
onInputGainChange={setInputGain}
onRecordMonoChange={setRecordMono}
onSampleRateChange={setSampleRate}
settings={settings}
onAudioSettingsChange={updateAudioSettings}
onUISettingsChange={updateUISettings}
onEditorSettingsChange={updateEditorSettings}
onPerformanceSettingsChange={updatePerformanceSettings}
onResetCategory={resetCategory}
/>
<React.Suspense fallback={null}>
<GlobalSettingsDialog
open={settingsDialogOpen}
onClose={() => setSettingsDialogOpen(false)}
recordingSettings={recordingSettings}
onInputGainChange={setInputGain}
onRecordMonoChange={setRecordMono}
onSampleRateChange={setSampleRate}
settings={settings}
onAudioSettingsChange={updateAudioSettings}
onUISettingsChange={updateUISettings}
onEditorSettingsChange={updateEditorSettings}
onPerformanceSettingsChange={updatePerformanceSettings}
onResetCategory={resetCategory}
/>
</React.Suspense>
{/* Export Dialog */}
<ExportDialog
open={exportDialogOpen}
onClose={() => setExportDialogOpen(false)}
onExport={handleExport}
isExporting={isExporting}
hasSelection={tracks.some(t => t.selection !== null)}
/>
<React.Suspense fallback={null}>
<ExportDialog
open={exportDialogOpen}
onClose={() => setExportDialogOpen(false)}
onExport={handleExport}
isExporting={isExporting}
hasSelection={tracks.some(t => t.selection !== null)}
/>
</React.Suspense>
{/* Projects Dialog */}
<ProjectsDialog
open={projectsDialogOpen}
onClose={() => setProjectsDialogOpen(false)}
projects={projects}
onNewProject={handleNewProject}
onLoadProject={handleLoadProject}
onDeleteProject={handleDeleteProject}
onDuplicateProject={handleDuplicateProject}
onExportProject={handleExportProject}
onImportProject={handleImportProject}
<React.Suspense fallback={null}>
<ProjectsDialog
open={projectsDialogOpen}
onClose={() => setProjectsDialogOpen(false)}
projects={projects}
onNewProject={handleNewProject}
onLoadProject={handleLoadProject}
onDeleteProject={handleDeleteProject}
onDuplicateProject={handleDuplicateProject}
onExportProject={handleExportProject}
onImportProject={handleImportProject}
/>
</React.Suspense>
{/* Browser Compatibility Dialog */}
<BrowserCompatDialog
open={browserCompatDialogOpen}
missingFeatures={browserCompatInfo.missingFeatures}
warnings={browserCompatInfo.warnings}
onClose={() => setBrowserCompatDialogOpen(false)}
/>
{/* Keyboard Shortcuts Dialog */}
<React.Suspense fallback={null}>
<KeyboardShortcutsDialog
open={shortcutsDialogOpen}
onClose={() => setShortcutsDialogOpen(false)}
/>
</React.Suspense>
</>
);
}

View File

@@ -21,6 +21,7 @@ import {
COLLAPSED_TRACK_HEIGHT,
MIN_TRACK_HEIGHT,
MAX_TRACK_HEIGHT,
DEFAULT_TRACK_HEIGHT,
} from "@/types/track";
import { Button } from "@/components/ui/Button";
import { Slider } from "@/components/ui/Slider";
@@ -606,7 +607,9 @@ export function Track({
}
};
const trackHeight = track.collapsed ? COLLAPSED_TRACK_HEIGHT : Math.max(track.height || MIN_TRACK_HEIGHT, MIN_TRACK_HEIGHT);
const trackHeight = track.collapsed
? COLLAPSED_TRACK_HEIGHT
: Math.max(track.height || DEFAULT_TRACK_HEIGHT, MIN_TRACK_HEIGHT);
// Track height resize handlers
const handleResizeStart = React.useCallback(
@@ -656,7 +659,9 @@ export function Track({
? "bg-primary/10 border-r-primary"
: "bg-card border-r-transparent hover:bg-accent/30",
)}
style={{ height: trackHeight }}
style={{
height: `${trackHeight}px`,
}}
onClick={(e) => {
e.stopPropagation();
if (onSelect) onSelect();

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown } from 'lucide-react';
import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react';
import { CircularKnob } from '@/components/ui/CircularKnob';
import { TrackFader } from './TrackFader';
import { cn } from '@/lib/utils/cn';
@@ -20,6 +20,7 @@ export interface TrackControlsProps {
showAutomation?: boolean;
showEffects?: boolean;
isRecording?: boolean;
mobileCollapsed?: boolean; // For mobile view collapsible controls
onNameChange: (name: string) => void;
onToggleCollapse: () => void;
onVolumeChange: (volume: number) => void;
@@ -33,6 +34,7 @@ export interface TrackControlsProps {
onVolumeTouchEnd?: () => void;
onPanTouchStart?: () => void;
onPanTouchEnd?: () => void;
onToggleMobileCollapse?: () => void;
className?: string;
}
@@ -50,6 +52,7 @@ export function TrackControls({
showAutomation = false,
showEffects = false,
isRecording = false,
mobileCollapsed = false,
onNameChange,
onToggleCollapse,
onVolumeChange,
@@ -63,6 +66,7 @@ export function TrackControls({
onVolumeTouchEnd,
onPanTouchStart,
onPanTouchEnd,
onToggleMobileCollapse,
className,
}: TrackControlsProps) {
const [isEditingName, setIsEditingName] = React.useState(false);
@@ -91,11 +95,238 @@ export function TrackControls({
}
};
return (
// Mobile collapsed view - minimal controls (like master controls)
if (mobileCollapsed) {
return (
<div className={cn(
'flex flex-col items-center gap-2 px-3 py-2 bg-card/50 border border-accent/50 rounded-lg w-full sm:hidden',
className
)}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-1 flex-1">
<button
onClick={onToggleCollapse}
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
title={collapsed ? 'Expand track' : 'Collapse track'}
>
{collapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div
className="text-xs font-bold uppercase tracking-wider"
style={{ color: trackColor }}
>
{trackName}
</div>
</div>
{onToggleMobileCollapse && (
<button
onClick={onToggleMobileCollapse}
className="p-1 hover:bg-accent/20 rounded transition-colors"
title="Expand track controls"
>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
<div className="flex items-center gap-1 w-full justify-center">
{onRecordToggle && (
<button
onClick={onRecordToggle}
className={cn(
'h-7 w-7 rounded-full flex items-center justify-center transition-all',
isRecordEnabled
? isRecording
? 'bg-red-500 shadow-lg shadow-red-500/50 animate-pulse'
: 'bg-red-500 shadow-md shadow-red-500/30'
: 'bg-card hover:bg-accent border border-border/50'
)}
title={isRecordEnabled ? 'Record Armed' : 'Arm for Recording'}
>
<Circle className={cn('h-3.5 w-3.5', isRecordEnabled ? 'fill-white text-white' : 'text-muted-foreground')} />
</button>
)}
<button
onClick={onMuteToggle}
className={cn(
'h-7 w-7 rounded-md flex items-center justify-center transition-all text-xs font-bold',
isMuted
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title={isMuted ? 'Unmute' : 'Mute'}
>
M
</button>
{onSoloToggle && (
<button
onClick={onSoloToggle}
className={cn(
'h-7 w-7 rounded-md flex items-center justify-center transition-all text-xs font-bold',
isSolo
? 'bg-yellow-500 text-white shadow-md shadow-yellow-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title={isSolo ? 'Unsolo' : 'Solo'}
>
S
</button>
)}
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all',
peakLevel > 0.95 ? 'bg-red-500' : peakLevel > 0.8 ? 'bg-yellow-500' : 'bg-green-500'
)}
style={{ width: `${peakLevel * 100}%` }}
/>
</div>
</div>
</div>
);
}
// Mobile expanded view - full controls (like master controls)
const mobileExpandedView = (
<div className={cn(
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
'flex flex-col items-center gap-3 px-3 py-3 bg-card/50 border border-accent/50 rounded-lg w-full sm:hidden',
className
)}>
{/* Header with collapse button */}
<div className="flex items-center justify-between w-full">
<button
onClick={onToggleCollapse}
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
title={collapsed ? 'Expand track' : 'Collapse track'}
>
{collapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div
className="text-xs font-bold uppercase tracking-wider flex-1 text-center"
style={{ color: trackColor }}
>
{trackName}
</div>
{onToggleMobileCollapse && (
<button
onClick={onToggleMobileCollapse}
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
title="Collapse track controls"
>
<ChevronUp className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
{/* Pan Control */}
<CircularKnob
value={pan}
onChange={onPanChange}
onTouchStart={onPanTouchStart}
onTouchEnd={onPanTouchEnd}
min={-1}
max={1}
step={0.01}
label="PAN"
size={48}
formatValue={(value: number) => {
if (Math.abs(value) < 0.01) return 'C';
if (value < 0) return `${Math.abs(value * 100).toFixed(0)}L`;
return `${(value * 100).toFixed(0)}R`;
}}
/>
{/* Volume Fader - Full height, not compressed */}
<div className="flex-1 flex justify-center items-center w-full min-h-[160px]">
<TrackFader
value={volume}
peakLevel={peakLevel}
rmsLevel={rmsLevel}
onChange={onVolumeChange}
onTouchStart={onVolumeTouchStart}
onTouchEnd={onVolumeTouchEnd}
/>
</div>
{/* Control buttons */}
<div className="flex items-center gap-1 w-full justify-center">
{onRecordToggle && (
<button
onClick={onRecordToggle}
className={cn(
'h-8 w-8 rounded-full flex items-center justify-center transition-all',
isRecordEnabled
? isRecording
? 'bg-red-500 shadow-lg shadow-red-500/50 animate-pulse'
: 'bg-red-500 shadow-md shadow-red-500/30'
: 'bg-card hover:bg-accent border border-border/50'
)}
title={isRecordEnabled ? 'Record Armed' : 'Arm for Recording'}
>
<Circle className={cn('h-3.5 w-3.5', isRecordEnabled ? 'fill-white text-white' : 'text-muted-foreground')} />
</button>
)}
<button
onClick={onMuteToggle}
className={cn(
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-xs font-bold',
isMuted
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title={isMuted ? 'Unmute' : 'Mute'}
>
M
</button>
{onSoloToggle && (
<button
onClick={onSoloToggle}
className={cn(
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-xs font-bold',
isSolo
? 'bg-yellow-500 text-white shadow-md shadow-yellow-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title={isSolo ? 'Unsolo' : 'Solo'}
>
S
</button>
)}
{onEffectsClick && (
<button
onClick={onEffectsClick}
className={cn(
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-xs font-bold',
showEffects
? 'bg-purple-500 text-white shadow-md shadow-purple-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title="Effects"
>
FX
</button>
)}
</div>
</div>
);
return (
<>
{/* Mobile view - Show expanded or collapsed */}
{!mobileCollapsed && mobileExpandedView}
{/* Desktop/tablet view - hidden on mobile */}
<div className={cn(
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border border-accent/50 rounded-lg hidden sm:flex',
className
)}>
{/* Track Name Header with Collapse Chevron */}
<div className="flex items-center gap-1 w-full">
<button
@@ -220,6 +451,7 @@ export function TrackControls({
)}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -167,8 +167,509 @@ export function TrackList({
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Track List - Two Column Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Mobile Layout - Single Column (Stacked: Controls → Waveform per track) */}
<div className="flex-1 flex flex-col overflow-y-auto overflow-x-hidden custom-scrollbar lg:hidden">
{tracks.map((track) => (
<div key={track.id} className="flex flex-col border-b border-border">
{/* Track Controls - Top */}
<Track
track={track}
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() => onUpdateTrack(track.id, { mute: !track.mute })}
onToggleSolo={() => onUpdateTrack(track.id, { solo: !track.solo })}
onToggleCollapse={() => onUpdateTrack(track.id, { collapsed: !track.collapsed })}
onVolumeChange={(volume) => onUpdateTrack(track.id, { volume })}
onPanChange={(pan) => onUpdateTrack(track.id, { pan })}
onRemove={() => onRemoveTrack(track.id)}
onNameChange={(name) => onUpdateTrack(track.id, { name })}
onUpdateTrack={onUpdateTrack}
onSeek={onSeek}
onLoadAudio={(buffer) => onUpdateTrack(track.id, { audioBuffer: buffer })}
onToggleEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemoveEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateEffect={(effectId, parameters) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onAddEffect={(effectType) => {
const newEffect = createEffect(effectType, EFFECT_NAMES[effectType]);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched}
isPlaying={isPlaying}
renderControlsOnly={true}
/>
{/* Track Waveform with Automation and Effects - Bottom */}
{!track.collapsed && (
<div className="flex flex-col">
{/* Waveform */}
<div className="min-h-[200px] overflow-x-auto custom-scrollbar">
<Track
track={track}
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() => onUpdateTrack(track.id, { mute: !track.mute })}
onToggleSolo={() => onUpdateTrack(track.id, { solo: !track.solo })}
onToggleCollapse={() => onUpdateTrack(track.id, { collapsed: !track.collapsed })}
onVolumeChange={(volume) => onUpdateTrack(track.id, { volume })}
onPanChange={(pan) => onUpdateTrack(track.id, { pan })}
onRemove={() => onRemoveTrack(track.id)}
onNameChange={(name) => onUpdateTrack(track.id, { name })}
onUpdateTrack={onUpdateTrack}
onSeek={onSeek}
onLoadAudio={(buffer) => onUpdateTrack(track.id, { audioBuffer: buffer })}
onToggleEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemoveEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateEffect={(effectId, parameters) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onAddEffect={(effectType) => {
const newEffect = createEffect(effectType, EFFECT_NAMES[effectType]);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched}
isPlaying={isPlaying}
renderWaveformOnly={true}
/>
</div>
{/* Automation Bar */}
{(() => {
const selectedParam = track.automation.selectedParameterId || 'volume';
const currentLane = track.automation.lanes.find(
l => l.parameterId === selectedParam
);
// Build available parameters list
const availableParameters: Array<{ id: string; name: string }> = [
{ id: 'volume', name: 'Volume' },
{ id: 'pan', name: 'Pan' },
];
// Add effect parameters
track.effectChain.effects.forEach((effect) => {
if (effect.parameters) {
Object.keys(effect.parameters).forEach((paramKey) => {
const parameterId = `effect.${effect.id}.${paramKey}`;
const paramName = `${effect.name} - ${paramKey.charAt(0).toUpperCase() + paramKey.slice(1)}`;
availableParameters.push({ id: parameterId, name: paramName });
});
}
});
// Get parameters that have automation lanes with points
const automatedParams = track.automation.lanes
.filter(lane => lane.points.length > 0)
.map(lane => {
const param = availableParameters.find(p => p.id === lane.parameterId);
return param ? param.name : lane.parameterName;
});
const modes = ['read', 'write', 'touch', 'latch'] as const;
const MODE_LABELS = { read: 'R', write: 'W', touch: 'T', latch: 'L' };
const MODE_COLORS = {
read: 'text-muted-foreground',
write: 'text-red-500',
touch: 'text-yellow-500',
latch: 'text-orange-500',
};
const currentModeIndex = modes.indexOf(currentLane?.mode || 'read');
return (
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm">
{/* Automation Header */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted border-t border-b border-border/30">
<span className="text-xs font-medium flex-shrink-0">Automation</span>
{/* Color indicator */}
{currentLane?.color && (
<div
className="w-1 h-4 rounded-full flex-shrink-0"
style={{ backgroundColor: currentLane.color }}
/>
)}
{/* Parameter labels - always visible */}
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
{automatedParams.map((paramName, index) => (
<span
key={index}
className="text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0 bg-primary/10 text-primary border border-primary/20"
>
{paramName}
</span>
))}
</div>
{/* Controls - only visible when expanded */}
{track.automationExpanded && (
<>
{/* Parameter selector */}
{availableParameters && availableParameters.length > 1 && (
<select
value={selectedParam}
onChange={(e) => onUpdateTrack(track.id, {
automation: { ...track.automation, selectedParameterId: e.target.value },
})}
className="text-xs font-medium text-foreground w-auto min-w-[120px] max-w-[200px] bg-background/50 border border-border/30 rounded px-1.5 py-0.5 hover:bg-background/80 focus:outline-none focus:ring-1 focus:ring-primary flex-shrink-0"
>
{availableParameters.map((param) => (
<option key={param.id} value={param.id}>
{param.name}
</option>
))}
</select>
)}
{/* Automation mode button */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const nextIndex = (currentModeIndex + 1) % modes.length;
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, mode: modes[nextIndex] } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title={`Automation mode: ${currentLane?.mode || 'read'} (click to cycle)`}
className={cn('h-5 w-5 text-[10px] font-bold flex-shrink-0', MODE_COLORS[currentLane?.mode || 'read'])}
>
{MODE_LABELS[currentLane?.mode || 'read']}
</Button>
{/* Height controls */}
<div className="flex flex-col gap-0 flex-shrink-0">
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const newHeight = Math.max(60, Math.min(200, currentLane.height + 20));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title="Increase lane height"
className="h-3 w-4 p-0"
>
<ChevronUp className="h-2.5 w-2.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const newHeight = Math.max(60, Math.min(200, currentLane.height - 20));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title="Decrease lane height"
className="h-3 w-4 p-0"
>
<ChevronDown className="h-2.5 w-2.5" />
</Button>
</div>
</>
)}
{/* Show/hide toggle */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
onUpdateTrack(track.id, { automationExpanded: !track.automationExpanded });
}}
title={track.automationExpanded ? 'Hide automation controls' : 'Show automation controls'}
className="h-5 w-5 flex-shrink-0"
>
{track.automationExpanded ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3 text-muted-foreground" />
)}
</Button>
</div>
{/* Automation Lane Content - Shown when expanded */}
{track.automationExpanded && (
<div className="overflow-x-auto custom-scrollbar">
<div
style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
}}
>
{track.automation.lanes
.filter((lane) => lane.parameterId === (track.automation.selectedParameterId || 'volume') && lane.visible)
.map((lane) => (
<AutomationLane
key={lane.id}
lane={lane}
zoom={zoom}
currentTime={currentTime}
duration={duration}
onAddPoint={(time, value) => {
const newPoint = createAutomationPoint({ time, value, curve: 'linear' });
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? { ...l, points: [...l.points, newPoint].sort((a, b) => a.time - b.time) }
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onUpdatePoint={(pointId, updates) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? {
...l,
points: l.points.map((p) =>
p.id === pointId ? { ...p, ...updates } : p
),
}
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onRemovePoint={(pointId) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onUpdateLane={(updates) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id ? { ...l, ...updates } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
/>
))}
</div>
</div>
)}
</div>
);
})()}
{/* Effects Bar */}
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm border-b border-border">
{/* Effects Header */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/50 border-t border-b border-border/30">
<span className="text-xs font-medium flex-shrink-0">Effects</span>
{/* Effect name labels */}
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
{track.effectChain.effects.map((effect) => (
<span
key={effect.id}
className={cn(
"text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0",
effect.enabled
? "bg-primary/10 text-primary border border-primary/20"
: "bg-muted/30 text-muted-foreground border border-border/30 opacity-60"
)}
>
{effect.name}
</span>
))}
</div>
{/* Add effect button - only visible when expanded */}
{track.effectsExpanded && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setEffectBrowserTrackId(track.id)}
title="Add effect"
className="h-5 w-5 flex-shrink-0"
>
<Plus className="h-3 w-3" />
</Button>
)}
{/* Show/hide toggle */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
onUpdateTrack(track.id, { effectsExpanded: !track.effectsExpanded });
}}
title={track.effectsExpanded ? 'Hide effects' : 'Show effects'}
className="h-5 w-5 flex-shrink-0"
>
{track.effectsExpanded ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3 text-muted-foreground" />
)}
</Button>
</div>
{/* Effects Content - Collapsible, horizontally scrollable */}
{track.effectsExpanded && (
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70 border-t border-border">
<div className="flex h-full gap-3 p-3">
{track.effectChain.effects.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-8 w-full">
No effects. Click + to add an effect.
</div>
) : (
track.effectChain.effects.map((effect) => (
<EffectDevice
key={effect.id}
effect={effect}
onToggleEnabled={() => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemove={() => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effect.id),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateParameters={(params) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, parameters: params } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onToggleExpanded={() => {
const updatedEffects = track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
);
onUpdateTrack(track.id, {
effectChain: { ...track.effectChain, effects: updatedEffects },
});
}}
/>
))
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
{/* Desktop Layout - Two Column Layout */}
<div className="flex-1 flex overflow-hidden hidden lg:flex">
{/* Left Column: Track Controls (Fixed Width, No Scroll - synced with waveforms) */}
<div ref={controlsScrollRef} className="w-60 flex-shrink-0 overflow-hidden pb-3 border-r border-border">
{tracks.map((track) => (
@@ -271,14 +772,14 @@ export function TrackList({
<div className="flex flex-col">
{tracks.map((track) => (
<React.Fragment key={track.id}>
{/* Track Waveform Row with bars stacked below - Fixed height container */}
{/* Track Waveform Row with bars stacked below - Total height matches track controls */}
<div
className="flex flex-col"
style={{
height: track.collapsed ? `${COLLAPSED_TRACK_HEIGHT}px` : `${Math.max(track.height || DEFAULT_TRACK_HEIGHT, MIN_TRACK_HEIGHT)}px`
}}
>
{/* Waveform - Takes remaining space, horizontally scrollable */}
{/* Waveform - Takes remaining space after bars */}
<div className="flex-1 min-h-0 relative">
{/* Upload hint for empty tracks - stays fixed as overlay */}
{!track.audioBuffer && !track.collapsed && (