feat: implement medium effort features - markers, web workers, and bezier automation
Implemented three major medium effort features to enhance the audio editor: **1. Region Markers System** - Add marker type definitions supporting point markers and regions - Create useMarkers hook for marker state management - Build MarkerTimeline component for visual marker display - Create MarkerDialog component for adding/editing markers - Add keyboard shortcuts: M (add marker), Shift+M (next), Shift+Ctrl+M (previous) - Support marker navigation, editing, and deletion **2. Web Worker for Computations** - Create audio worker for offloading heavy computations - Implement worker functions: generatePeaks, generateMinMaxPeaks, normalizePeaks, analyzeAudio, findPeak - Build useAudioWorker hook for easy worker integration - Integrate worker into Waveform component with peak caching - Significantly improve UI responsiveness during waveform generation **3. Bezier Curve Automation** - Enhance interpolateAutomationValue to support Bezier curves - Implement cubic Bezier interpolation with control handles - Add createSmoothHandles for auto-smooth curve generation - Add generateBezierCurvePoints for smooth curve rendering - Support bezier alongside existing linear and step curves All features are type-safe and integrate seamlessly with the existing codebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
216
components/markers/MarkerTimeline.tsx
Normal file
216
components/markers/MarkerTimeline.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { Marker } from '@/types/marker';
|
||||
import { Flag, Edit2, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface MarkerTimelineProps {
|
||||
markers: Marker[];
|
||||
duration: number;
|
||||
currentTime: number;
|
||||
onMarkerClick?: (marker: Marker) => void;
|
||||
onMarkerEdit?: (marker: Marker) => void;
|
||||
onMarkerDelete?: (markerId: string) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkerTimeline({
|
||||
markers,
|
||||
duration,
|
||||
currentTime,
|
||||
onMarkerClick,
|
||||
onMarkerEdit,
|
||||
onMarkerDelete,
|
||||
onSeek,
|
||||
className,
|
||||
}: MarkerTimelineProps) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [hoveredMarkerId, setHoveredMarkerId] = React.useState<string | null>(null);
|
||||
|
||||
const timeToX = React.useCallback(
|
||||
(time: number): number => {
|
||||
if (!containerRef.current) return 0;
|
||||
const width = containerRef.current.clientWidth;
|
||||
return (time / duration) * width;
|
||||
},
|
||||
[duration]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative w-full h-8 bg-muted/30 border-b border-border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Markers */}
|
||||
{markers.map((marker) => {
|
||||
const x = timeToX(marker.time);
|
||||
const isHovered = hoveredMarkerId === marker.id;
|
||||
|
||||
if (marker.type === 'point') {
|
||||
return (
|
||||
<div
|
||||
key={marker.id}
|
||||
className="absolute top-0 bottom-0 group cursor-pointer"
|
||||
style={{ left: `${x}px` }}
|
||||
onMouseEnter={() => setHoveredMarkerId(marker.id)}
|
||||
onMouseLeave={() => setHoveredMarkerId(null)}
|
||||
onClick={() => {
|
||||
onMarkerClick?.(marker);
|
||||
onSeek?.(marker.time);
|
||||
}}
|
||||
>
|
||||
{/* Marker line */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 bottom-0 w-0.5 transition-colors',
|
||||
isHovered ? 'bg-primary' : 'bg-primary/60'
|
||||
)}
|
||||
style={{ backgroundColor: marker.color }}
|
||||
/>
|
||||
|
||||
{/* Marker flag */}
|
||||
<Flag
|
||||
className={cn(
|
||||
'absolute top-0.5 -left-2 h-4 w-4 transition-colors',
|
||||
isHovered ? 'text-primary' : 'text-primary/60'
|
||||
)}
|
||||
style={{ color: marker.color }}
|
||||
/>
|
||||
|
||||
{/* Hover tooltip with actions */}
|
||||
{isHovered && (
|
||||
<div className="absolute top-full left-0 mt-1 z-10 bg-popover border border-border rounded shadow-lg p-2 min-w-[200px]">
|
||||
<div className="text-xs font-medium mb-1">{marker.name}</div>
|
||||
{marker.description && (
|
||||
<div className="text-xs text-muted-foreground mb-2">{marker.description}</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{onMarkerEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkerEdit(marker);
|
||||
}}
|
||||
title="Edit marker"
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onMarkerDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkerDelete(marker.id);
|
||||
}}
|
||||
title="Delete marker"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Region marker
|
||||
const endX = timeToX(marker.endTime || marker.time);
|
||||
const width = endX - x;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={marker.id}
|
||||
className="absolute top-0 bottom-0 group cursor-pointer"
|
||||
style={{ left: `${x}px`, width: `${width}px` }}
|
||||
onMouseEnter={() => setHoveredMarkerId(marker.id)}
|
||||
onMouseLeave={() => setHoveredMarkerId(null)}
|
||||
onClick={() => {
|
||||
onMarkerClick?.(marker);
|
||||
onSeek?.(marker.time);
|
||||
}}
|
||||
>
|
||||
{/* Region background */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity',
|
||||
isHovered ? 'opacity-30' : 'opacity-20'
|
||||
)}
|
||||
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
|
||||
/>
|
||||
|
||||
{/* Region borders */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 w-0.5"
|
||||
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 right-0 w-0.5"
|
||||
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
|
||||
/>
|
||||
|
||||
{/* Region label */}
|
||||
<div
|
||||
className="absolute top-0.5 left-1 text-[10px] font-medium truncate pr-1"
|
||||
style={{ color: marker.color || 'var(--color-primary)', maxWidth: `${width - 8}px` }}
|
||||
>
|
||||
{marker.name}
|
||||
</div>
|
||||
|
||||
{/* Hover tooltip with actions */}
|
||||
{isHovered && (
|
||||
<div className="absolute top-full left-0 mt-1 z-10 bg-popover border border-border rounded shadow-lg p-2 min-w-[200px]">
|
||||
<div className="text-xs font-medium mb-1">{marker.name}</div>
|
||||
{marker.description && (
|
||||
<div className="text-xs text-muted-foreground mb-2">{marker.description}</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{onMarkerEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkerEdit(marker);
|
||||
}}
|
||||
title="Edit marker"
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onMarkerDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkerDelete(marker.id);
|
||||
}}
|
||||
title="Delete marker"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user