Files
audio-ui/components/markers/MarkerTimeline.tsx
Sebastian Krüger 119c8c2942 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>
2025-11-20 08:25:33 +01:00

217 lines
7.5 KiB
TypeScript

'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>
);
}