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:
@@ -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
149
lib/utils/audio-cleanup.ts
Normal 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
133
lib/utils/browser-compat.ts
Normal 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
160
lib/utils/memory-limits.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user