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

@@ -3,6 +3,7 @@
*/
import { getAudioContext } from './context';
import { checkFileMemoryLimit, type MemoryCheckResult } from '../utils/memory-limits';
export interface ImportOptions {
convertToMono?: boolean;
@@ -248,6 +249,15 @@ export function isSupportedAudioFormat(file: File): boolean {
/\.(wav|mp3|ogg|webm|flac|aac|m4a|aiff|aif)$/i.test(file.name);
}
/**
* Check memory requirements for an audio file before decoding
* @param file File to check
* @returns Memory check result with warning if file is large
*/
export function checkAudioFileMemory(file: File): MemoryCheckResult {
return checkFileMemoryLimit(file.size);
}
/**
* Format duration in seconds to MM:SS format
*/

149
lib/utils/audio-cleanup.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* Audio cleanup utilities to prevent memory leaks
*/
/**
* Safely disconnect and cleanup an AudioNode
*/
export function cleanupAudioNode(node: AudioNode | null | undefined): void {
if (!node) return;
try {
node.disconnect();
} catch (error) {
// Node may already be disconnected, ignore error
console.debug('AudioNode cleanup error (expected):', error);
}
}
/**
* Cleanup multiple audio nodes
*/
export function cleanupAudioNodes(nodes: Array<AudioNode | null | undefined>): void {
nodes.forEach(cleanupAudioNode);
}
/**
* Safely stop and cleanup an AudioBufferSourceNode
*/
export function cleanupAudioSource(source: AudioBufferSourceNode | null | undefined): void {
if (!source) return;
try {
source.stop();
} catch (error) {
// Source may already be stopped, ignore error
console.debug('AudioSource stop error (expected):', error);
}
cleanupAudioNode(source);
}
/**
* Cleanup canvas and release resources
*/
export function cleanupCanvas(canvas: HTMLCanvasElement | null | undefined): void {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (ctx) {
// Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Reset transform
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
// Release context (helps with memory)
canvas.width = 0;
canvas.height = 0;
}
/**
* Cancel animation frame safely
*/
export function cleanupAnimationFrame(frameId: number | null | undefined): void {
if (frameId !== null && frameId !== undefined) {
cancelAnimationFrame(frameId);
}
}
/**
* Cleanup media stream tracks
*/
export function cleanupMediaStream(stream: MediaStream | null | undefined): void {
if (!stream) return;
stream.getTracks().forEach(track => {
track.stop();
});
}
/**
* Create a cleanup registry for managing multiple cleanup tasks
*/
export class CleanupRegistry {
private cleanupTasks: Array<() => void> = [];
/**
* Register a cleanup task
*/
register(cleanup: () => void): void {
this.cleanupTasks.push(cleanup);
}
/**
* Register an audio node for cleanup
*/
registerAudioNode(node: AudioNode): void {
this.register(() => cleanupAudioNode(node));
}
/**
* Register an audio source for cleanup
*/
registerAudioSource(source: AudioBufferSourceNode): void {
this.register(() => cleanupAudioSource(source));
}
/**
* Register a canvas for cleanup
*/
registerCanvas(canvas: HTMLCanvasElement): void {
this.register(() => cleanupCanvas(canvas));
}
/**
* Register an animation frame for cleanup
*/
registerAnimationFrame(frameId: number): void {
this.register(() => cleanupAnimationFrame(frameId));
}
/**
* Register a media stream for cleanup
*/
registerMediaStream(stream: MediaStream): void {
this.register(() => cleanupMediaStream(stream));
}
/**
* Execute all cleanup tasks and clear the registry
*/
cleanup(): void {
this.cleanupTasks.forEach(task => {
try {
task();
} catch (error) {
console.error('Cleanup task failed:', error);
}
});
this.cleanupTasks = [];
}
/**
* Get the number of registered cleanup tasks
*/
get size(): number {
return this.cleanupTasks.length;
}
}

133
lib/utils/browser-compat.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Browser compatibility checking utilities
*/
export interface BrowserCompatibility {
isSupported: boolean;
missingFeatures: string[];
warnings: string[];
}
/**
* Check if all required browser features are supported
*/
export function checkBrowserCompatibility(): BrowserCompatibility {
const missingFeatures: string[] = [];
const warnings: string[] = [];
// Check if running in browser
if (typeof window === 'undefined') {
return {
isSupported: true,
missingFeatures: [],
warnings: [],
};
}
// Check Web Audio API
if (!window.AudioContext && !(window as any).webkitAudioContext) {
missingFeatures.push('Web Audio API');
}
// Check IndexedDB
if (!window.indexedDB) {
missingFeatures.push('IndexedDB');
}
// Check localStorage
try {
localStorage.setItem('test', 'test');
localStorage.removeItem('test');
} catch (e) {
missingFeatures.push('LocalStorage');
}
// Check Canvas API
const canvas = document.createElement('canvas');
if (!canvas.getContext || !canvas.getContext('2d')) {
missingFeatures.push('Canvas API');
}
// Check MediaDevices API (for recording)
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
warnings.push('Microphone recording not supported (requires HTTPS or localhost)');
}
// Check File API
if (!window.File || !window.FileReader || !window.FileList || !window.Blob) {
missingFeatures.push('File API');
}
// Check AudioWorklet support (optional)
if (window.AudioContext && !AudioContext.prototype.hasOwnProperty('audioWorklet')) {
warnings.push('AudioWorklet not supported (some features may have higher latency)');
}
// Check OfflineAudioContext
if (!window.OfflineAudioContext && !(window as any).webkitOfflineAudioContext) {
missingFeatures.push('OfflineAudioContext (required for audio processing)');
}
return {
isSupported: missingFeatures.length === 0,
missingFeatures,
warnings,
};
}
/**
* Get user-friendly browser name
*/
export function getBrowserInfo(): { name: string; version: string } {
// Check if running in browser
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return { name: 'Unknown', version: 'Unknown' };
}
const userAgent = navigator.userAgent;
let name = 'Unknown';
let version = 'Unknown';
if (userAgent.indexOf('Chrome') > -1 && userAgent.indexOf('Edg') === -1) {
name = 'Chrome';
const match = userAgent.match(/Chrome\/(\d+)/);
version = match ? match[1] : 'Unknown';
} else if (userAgent.indexOf('Edg') > -1) {
name = 'Edge';
const match = userAgent.match(/Edg\/(\d+)/);
version = match ? match[1] : 'Unknown';
} else if (userAgent.indexOf('Firefox') > -1) {
name = 'Firefox';
const match = userAgent.match(/Firefox\/(\d+)/);
version = match ? match[1] : 'Unknown';
} else if (userAgent.indexOf('Safari') > -1 && userAgent.indexOf('Chrome') === -1) {
name = 'Safari';
const match = userAgent.match(/Version\/(\d+)/);
version = match ? match[1] : 'Unknown';
}
return { name, version };
}
/**
* Check if browser version meets minimum requirements
*/
export function checkMinimumVersion(): boolean {
const { name, version } = getBrowserInfo();
const versionNum = parseInt(version, 10);
const minimumVersions: Record<string, number> = {
Chrome: 90,
Edge: 90,
Firefox: 88,
Safari: 14,
};
const minVersion = minimumVersions[name];
if (!minVersion) {
// Unknown browser, assume it's ok
return true;
}
return versionNum >= minVersion;
}

160
lib/utils/memory-limits.ts Normal file
View File

@@ -0,0 +1,160 @@
/**
* Memory limit checking utilities for audio file handling
*/
export interface MemoryCheckResult {
allowed: boolean;
warning?: string;
estimatedMemoryMB: number;
availableMemoryMB?: number;
}
/**
* Estimate memory required for an audio buffer
* @param duration Duration in seconds
* @param sampleRate Sample rate (default: 48000 Hz)
* @param channels Number of channels (default: 2 for stereo)
* @returns Estimated memory in MB
*/
export function estimateAudioMemory(
duration: number,
sampleRate: number = 48000,
channels: number = 2
): number {
// Each sample is a 32-bit float (4 bytes)
const bytesPerSample = 4;
const totalSamples = duration * sampleRate * channels;
const bytes = totalSamples * bytesPerSample;
// Convert to MB
return bytes / (1024 * 1024);
}
/**
* Get available device memory if supported
* @returns Available memory in MB, or undefined if not supported
*/
export function getAvailableMemory(): number | undefined {
if (typeof navigator === 'undefined') return undefined;
// @ts-ignore - deviceMemory is not in TypeScript types yet
const deviceMemory = navigator.deviceMemory;
if (typeof deviceMemory === 'number') {
// deviceMemory is in GB, convert to MB
return deviceMemory * 1024;
}
return undefined;
}
/**
* Check if a file size is within safe memory limits
* @param fileSizeBytes File size in bytes
* @returns Memory check result
*/
export function checkFileMemoryLimit(fileSizeBytes: number): MemoryCheckResult {
// Estimate memory usage (audio files decompress to ~10x their size)
const estimatedMemoryMB = (fileSizeBytes / (1024 * 1024)) * 10;
const availableMemoryMB = getAvailableMemory();
// Conservative limits
const WARN_THRESHOLD_MB = 100; // Warn if file will use > 100MB
const MAX_RECOMMENDED_MB = 500; // Don't recommend files > 500MB
if (estimatedMemoryMB > MAX_RECOMMENDED_MB) {
return {
allowed: false,
warning: `This file may require ${Math.round(estimatedMemoryMB)}MB of memory. ` +
`Files larger than ${MAX_RECOMMENDED_MB}MB are not recommended as they may cause performance issues or crashes.`,
estimatedMemoryMB,
availableMemoryMB,
};
}
if (estimatedMemoryMB > WARN_THRESHOLD_MB) {
const warning = availableMemoryMB
? `This file will require approximately ${Math.round(estimatedMemoryMB)}MB of memory. ` +
`Your device has ${Math.round(availableMemoryMB)}MB available.`
: `This file will require approximately ${Math.round(estimatedMemoryMB)}MB of memory. ` +
`Large files may cause performance issues on devices with limited memory.`;
return {
allowed: true,
warning,
estimatedMemoryMB,
availableMemoryMB,
};
}
return {
allowed: true,
estimatedMemoryMB,
availableMemoryMB,
};
}
/**
* Check if an audio buffer is within safe memory limits
* @param duration Duration in seconds
* @param sampleRate Sample rate
* @param channels Number of channels
* @returns Memory check result
*/
export function checkAudioBufferMemoryLimit(
duration: number,
sampleRate: number = 48000,
channels: number = 2
): MemoryCheckResult {
const estimatedMemoryMB = estimateAudioMemory(duration, sampleRate, channels);
const availableMemoryMB = getAvailableMemory();
const WARN_THRESHOLD_MB = 100;
const MAX_RECOMMENDED_MB = 500;
if (estimatedMemoryMB > MAX_RECOMMENDED_MB) {
return {
allowed: false,
warning: `This audio (${Math.round(duration / 60)} minutes) will require ${Math.round(estimatedMemoryMB)}MB of memory. ` +
`Audio longer than ${Math.round((MAX_RECOMMENDED_MB / sampleRate / channels / 4) / 60)} minutes may cause performance issues.`,
estimatedMemoryMB,
availableMemoryMB,
};
}
if (estimatedMemoryMB > WARN_THRESHOLD_MB) {
const warning = availableMemoryMB
? `This audio will require approximately ${Math.round(estimatedMemoryMB)}MB of memory. ` +
`Your device has ${Math.round(availableMemoryMB)}MB available.`
: `This audio will require approximately ${Math.round(estimatedMemoryMB)}MB of memory.`;
return {
allowed: true,
warning,
estimatedMemoryMB,
availableMemoryMB,
};
}
return {
allowed: true,
estimatedMemoryMB,
availableMemoryMB,
};
}
/**
* Format memory size in human-readable format
* @param bytes Size in bytes
* @returns Formatted string (e.g., "1.5 MB", "250 KB")
*/
export function formatMemorySize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
} else if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} else {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
}