mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-02 16:45:15 -07:00
formatting
This commit is contained in:
@@ -1,23 +1,39 @@
|
|||||||
import { Copy, GripHorizontal, Minus, Pause, Play, Plus, Scissors, Square, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
Copy,
|
||||||
|
GripHorizontal,
|
||||||
|
Minus,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Scissors,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useMoveStoryItem, useTrimStoryItem, useSplitStoryItem, useDuplicateStoryItem, useRemoveStoryItem } from '@/lib/hooks/useStories';
|
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import {
|
||||||
|
useDuplicateStoryItem,
|
||||||
|
useMoveStoryItem,
|
||||||
|
useRemoveStoryItem,
|
||||||
|
useSplitStoryItem,
|
||||||
|
useTrimStoryItem,
|
||||||
|
} from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
// Clip waveform component with trim support
|
// Clip waveform component with trim support
|
||||||
function ClipWaveform({
|
function ClipWaveform({
|
||||||
generationId,
|
generationId,
|
||||||
width,
|
width,
|
||||||
trimStartMs,
|
trimStartMs,
|
||||||
trimEndMs,
|
trimEndMs,
|
||||||
duration
|
duration,
|
||||||
}: {
|
}: {
|
||||||
generationId: string;
|
generationId: string;
|
||||||
width: number;
|
width: number;
|
||||||
trimStartMs: number;
|
trimStartMs: number;
|
||||||
trimEndMs: number;
|
trimEndMs: number;
|
||||||
@@ -28,15 +44,13 @@ function ClipWaveform({
|
|||||||
|
|
||||||
// Calculate the full waveform width based on the original duration
|
// Calculate the full waveform width based on the original duration
|
||||||
// The visible portion (width) represents the effective duration after trimming
|
// The visible portion (width) represents the effective duration after trimming
|
||||||
const effectiveDurationMs = (duration * 1000) - trimStartMs - trimEndMs;
|
const effectiveDurationMs = duration * 1000 - trimStartMs - trimEndMs;
|
||||||
const fullWaveformWidth = effectiveDurationMs > 0
|
const fullWaveformWidth =
|
||||||
? (width / effectiveDurationMs) * (duration * 1000)
|
effectiveDurationMs > 0 ? (width / effectiveDurationMs) * (duration * 1000) : width;
|
||||||
: width;
|
|
||||||
|
|
||||||
// Calculate how much to offset the waveform to hide the trimmed start
|
// Calculate how much to offset the waveform to hide the trimmed start
|
||||||
const offsetX = effectiveDurationMs > 0
|
const offsetX =
|
||||||
? (trimStartMs / (duration * 1000)) * fullWaveformWidth
|
effectiveDurationMs > 0 ? (trimStartMs / (duration * 1000)) * fullWaveformWidth : 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!waveformRef.current || fullWaveformWidth < 20) return;
|
if (!waveformRef.current || fullWaveformWidth < 20) return;
|
||||||
@@ -79,9 +93,9 @@ function ClipWaveform({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full h-full opacity-60 overflow-hidden">
|
<div className="w-full h-full opacity-60 overflow-hidden">
|
||||||
{/* Inner container that holds the full waveform, offset to show only visible portion */}
|
{/* Inner container that holds the full waveform, offset to show only visible portion */}
|
||||||
<div
|
<div
|
||||||
ref={waveformRef}
|
ref={waveformRef}
|
||||||
style={{
|
style={{
|
||||||
width: `${fullWaveformWidth}px`,
|
width: `${fullWaveformWidth}px`,
|
||||||
transform: `translateX(-${offsetX}px)`,
|
transform: `translateX(-${offsetX}px)`,
|
||||||
}}
|
}}
|
||||||
@@ -121,16 +135,19 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const duplicateItem = useDuplicateStoryItem();
|
const duplicateItem = useDuplicateStoryItem();
|
||||||
const removeItem = useRemoveStoryItem();
|
const removeItem = useRemoveStoryItem();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||||
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
||||||
|
|
||||||
// Trim state
|
// Trim state
|
||||||
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
||||||
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
||||||
const [trimStartX, setTrimStartX] = useState(0);
|
const [trimStartX, setTrimStartX] = useState(0);
|
||||||
const [tempTrimValues, setTempTrimValues] = useState<{ trim_start_ms: number; trim_end_ms: number } | null>(null);
|
const [tempTrimValues, setTempTrimValues] = useState<{
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Track editor height from store (shared with FloatingGenerateBox)
|
// Track editor height from store (shared with FloatingGenerateBox)
|
||||||
const editorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const editorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
@@ -156,10 +173,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
...items.map((item) => {
|
...items.map((item) => {
|
||||||
const trimStart = item.trim_start_ms || 0;
|
const trimStart = item.trim_start_ms || 0;
|
||||||
const trimEnd = item.trim_end_ms || 0;
|
const trimEnd = item.trim_end_ms || 0;
|
||||||
const effectiveDuration = (item.duration * 1000) - trimStart - trimEnd;
|
const effectiveDuration = item.duration * 1000 - trimStart - trimEnd;
|
||||||
return item.start_time_ms + effectiveDuration;
|
return item.start_time_ms + effectiveDuration;
|
||||||
}),
|
}),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
setActiveStory(storyId, items, totalDuration);
|
setActiveStory(storyId, items, totalDuration);
|
||||||
}
|
}
|
||||||
@@ -214,11 +231,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
// Calculate total duration (using effective durations)
|
// Calculate total duration (using effective durations)
|
||||||
const totalDurationMs = useMemo(() => {
|
const totalDurationMs = useMemo(() => {
|
||||||
if (items.length === 0) return 10000; // Default 10 seconds
|
if (items.length === 0) return 10000; // Default 10 seconds
|
||||||
return Math.max(
|
return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
|
||||||
...items.map((item) => item.start_time_ms + getEffectiveDuration(item)),
|
}, [items, getEffectiveDuration]);
|
||||||
10000
|
|
||||||
);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
// Calculate timeline width - at least full container width
|
// Calculate timeline width - at least full container width
|
||||||
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
|
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
|
||||||
@@ -246,15 +260,9 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const msToPixels = useCallback(
|
const msToPixels = useCallback((ms: number) => (ms / 1000) * pixelsPerSecond, [pixelsPerSecond]);
|
||||||
(ms: number) => (ms / 1000) * pixelsPerSecond,
|
|
||||||
[pixelsPerSecond]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pixelsToMs = useCallback(
|
const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
|
||||||
(px: number) => (px / pixelsPerSecond) * 1000,
|
|
||||||
[pixelsPerSecond]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
|
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
|
||||||
@@ -265,22 +273,28 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Resize handlers
|
// Resize handlers
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const handleResizeStart = useCallback(
|
||||||
e.preventDefault();
|
(e: React.MouseEvent) => {
|
||||||
setIsResizing(true);
|
e.preventDefault();
|
||||||
resizeStartY.current = e.clientY;
|
setIsResizing(true);
|
||||||
resizeStartHeight.current = editorHeight;
|
resizeStartY.current = e.clientY;
|
||||||
}, [editorHeight]);
|
resizeStartHeight.current = editorHeight;
|
||||||
|
},
|
||||||
|
[editorHeight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
const handleResizeMove = useCallback(
|
||||||
if (!isResizing) return;
|
(e: MouseEvent) => {
|
||||||
const deltaY = resizeStartY.current - e.clientY;
|
if (!isResizing) return;
|
||||||
const newHeight = Math.min(
|
const deltaY = resizeStartY.current - e.clientY;
|
||||||
MAX_EDITOR_HEIGHT,
|
const newHeight = Math.min(
|
||||||
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY)
|
MAX_EDITOR_HEIGHT,
|
||||||
);
|
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY),
|
||||||
setEditorHeight(newHeight);
|
);
|
||||||
}, [isResizing, setEditorHeight]);
|
setEditorHeight(newHeight);
|
||||||
|
},
|
||||||
|
[isResizing, setEditorHeight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResizeEnd = useCallback(() => {
|
const handleResizeEnd = useCallback(() => {
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
@@ -328,44 +342,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const trimStartItemRef = useRef<{
|
||||||
const trimStartItemRef = useRef<{ item: StoryItemDetail; initialTrimStart: number; initialTrimEnd: number } | null>(null);
|
item: StoryItemDetail;
|
||||||
|
initialTrimStart: number;
|
||||||
|
initialTrimEnd: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleTrimMove = useCallback(
|
const handleTrimMove = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (!trimmingItem || !trimSide || !trimStartItemRef.current) return;
|
if (!trimmingItem || !trimSide || !trimStartItemRef.current) return;
|
||||||
|
|
||||||
const deltaX = e.clientX - trimStartX;
|
const deltaX = e.clientX - trimStartX;
|
||||||
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
|
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
|
||||||
|
|
||||||
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
||||||
const originalDurationMs = item.duration * 1000;
|
const originalDurationMs = item.duration * 1000;
|
||||||
|
|
||||||
let newTrimStart = initialTrimStart;
|
let newTrimStart = initialTrimStart;
|
||||||
let newTrimEnd = initialTrimEnd;
|
let newTrimEnd = initialTrimEnd;
|
||||||
|
|
||||||
if (trimSide === 'start') {
|
if (trimSide === 'start') {
|
||||||
// Moving right increases trim_start (trims more from start)
|
// Moving right increases trim_start (trims more from start)
|
||||||
// Moving left decreases trim_start (restores from start)
|
// Moving left decreases trim_start (restores from start)
|
||||||
newTrimStart = Math.round(Math.max(0, Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100)));
|
newTrimStart = Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Moving right decreases trim_end (restores from end)
|
// Moving right decreases trim_end (restores from end)
|
||||||
// Moving left increases trim_end (trims more from end)
|
// Moving left increases trim_end (trims more from end)
|
||||||
newTrimEnd = Math.round(Math.max(0, Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100)));
|
newTrimEnd = Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that we don't exceed duration
|
// Validate that we don't exceed duration
|
||||||
if (newTrimStart + newTrimEnd >= originalDurationMs - 100) {
|
if (newTrimStart + newTrimEnd >= originalDurationMs - 100) {
|
||||||
return; // Don't allow trimming to less than 100ms
|
return; // Don't allow trimming to less than 100ms
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update temporary trim values for visual feedback
|
// Update temporary trim values for visual feedback
|
||||||
setTempTrimValues({
|
setTempTrimValues({
|
||||||
trim_start_ms: newTrimStart,
|
trim_start_ms: newTrimStart,
|
||||||
trim_end_ms: newTrimEnd,
|
trim_end_ms: newTrimEnd,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[trimmingItem, trimSide, trimStartX, pixelsToMs]
|
[trimmingItem, trimSide, trimStartX, pixelsToMs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTrimEnd = useCallback(() => {
|
const handleTrimEnd = useCallback(() => {
|
||||||
@@ -378,12 +405,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
const { initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
||||||
|
|
||||||
// Use temporary trim values if available, otherwise use initial values
|
// Use temporary trim values if available, otherwise use initial values
|
||||||
// Ensure values are integers for the backend
|
// Ensure values are integers for the backend
|
||||||
const finalTrimStart = Math.round(tempTrimValues?.trim_start_ms ?? initialTrimStart);
|
const finalTrimStart = Math.round(tempTrimValues?.trim_start_ms ?? initialTrimStart);
|
||||||
const finalTrimEnd = Math.round(tempTrimValues?.trim_end_ms ?? initialTrimEnd);
|
const finalTrimEnd = Math.round(tempTrimValues?.trim_end_ms ?? initialTrimEnd);
|
||||||
|
|
||||||
// Only update if values changed
|
// Only update if values changed
|
||||||
if (finalTrimStart !== initialTrimStart || finalTrimEnd !== initialTrimEnd) {
|
if (finalTrimStart !== initialTrimStart || finalTrimEnd !== initialTrimEnd) {
|
||||||
trimItem.mutate(
|
trimItem.mutate(
|
||||||
@@ -403,10 +430,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTrimmingItem(null);
|
setTrimmingItem(null);
|
||||||
setTrimSide(null);
|
setTrimSide(null);
|
||||||
setTempTrimValues(null);
|
setTempTrimValues(null);
|
||||||
@@ -415,13 +442,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
const handleSplit = useCallback(() => {
|
const handleSplit = useCallback(() => {
|
||||||
if (!selectedClipId) return;
|
if (!selectedClipId) return;
|
||||||
|
|
||||||
const item = items.find((i) => i.id === selectedClipId);
|
const item = items.find((i) => i.id === selectedClipId);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
const splitTimeMs = currentTimeMs - item.start_time_ms;
|
const splitTimeMs = currentTimeMs - item.start_time_ms;
|
||||||
const effectiveDuration = getEffectiveDuration(item);
|
const effectiveDuration = getEffectiveDuration(item);
|
||||||
|
|
||||||
if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) {
|
if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Invalid split point',
|
title: 'Invalid split point',
|
||||||
@@ -448,9 +475,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}, [selectedClipId, items, currentTimeMs, getEffectiveDuration, storyId, splitItem, toast, setSelectedClipId]);
|
}, [
|
||||||
|
selectedClipId,
|
||||||
|
items,
|
||||||
|
currentTimeMs,
|
||||||
|
getEffectiveDuration,
|
||||||
|
storyId,
|
||||||
|
splitItem,
|
||||||
|
toast,
|
||||||
|
setSelectedClipId,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!selectedClipId) return;
|
if (!selectedClipId) return;
|
||||||
@@ -468,7 +504,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}, [selectedClipId, storyId, duplicateItem, toast]);
|
}, [selectedClipId, storyId, duplicateItem, toast]);
|
||||||
|
|
||||||
@@ -491,7 +527,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
|
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
|
||||||
|
|
||||||
@@ -539,10 +575,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
}
|
}
|
||||||
}, [trimmingItem, handleTrimMove, handleTrimEnd]);
|
}, [trimmingItem, handleTrimMove, handleTrimEnd]);
|
||||||
|
|
||||||
const handleDragStart = (
|
const handleDragStart = (e: React.MouseEvent, item: StoryItemDetail) => {
|
||||||
e: React.MouseEvent,
|
|
||||||
item: StoryItemDetail
|
|
||||||
) => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!tracksRef.current) return;
|
if (!tracksRef.current) return;
|
||||||
|
|
||||||
@@ -568,7 +601,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
setDragPosition({ x: Math.max(0, x), y });
|
setDragPosition({ x: Math.max(0, x), y });
|
||||||
},
|
},
|
||||||
[draggingItem, dragOffset]
|
[draggingItem, dragOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
@@ -610,7 +643,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,257 +683,270 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
||||||
<div className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative" ref={containerRef}>
|
<div
|
||||||
{/* Resize handle at top */}
|
className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative"
|
||||||
<button
|
ref={containerRef}
|
||||||
type="button"
|
|
||||||
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center hover:bg-muted/50 transition-colors z-20 group"
|
|
||||||
onMouseDown={handleResizeStart}
|
|
||||||
aria-label="Resize track editor"
|
|
||||||
>
|
>
|
||||||
<GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
{/* Resize handle at top */}
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
{/* Toolbar */}
|
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center hover:bg-muted/50 transition-colors z-20 group"
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
|
onMouseDown={handleResizeStart}
|
||||||
{/* Play controls - left side */}
|
aria-label="Resize track editor"
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
|
|
||||||
{isCurrentlyPlaying ? (
|
|
||||||
<Pause className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleStop} disabled={!isCurrentlyPlaying}>
|
|
||||||
<Square className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
|
||||||
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clip editing controls - center */}
|
|
||||||
{selectedClipId && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleSplit}
|
|
||||||
title="Split at playhead (S)"
|
|
||||||
>
|
|
||||||
<Scissors className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleDuplicate}
|
|
||||||
title="Duplicate (Cmd/Ctrl+D)"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleDelete}
|
|
||||||
title="Delete (Delete/Backspace)"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Zoom controls - right side */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">Zoom:</span>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
|
||||||
<Minus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline container with track labels sidebar */}
|
|
||||||
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
|
|
||||||
{/* Track labels sidebar - fixed width */}
|
|
||||||
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
|
|
||||||
{/* Spacer for time ruler */}
|
|
||||||
<div className="h-6 border-b bg-muted/30" />
|
|
||||||
{/* Track labels */}
|
|
||||||
<div style={{ height: `${tracksAreaHeight}px` }}>
|
|
||||||
{tracks.map((trackNumber, index) => (
|
|
||||||
<div
|
|
||||||
key={trackNumber}
|
|
||||||
className={cn(
|
|
||||||
'border-b flex items-center justify-center',
|
|
||||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
|
||||||
)}
|
|
||||||
style={{ height: `${TRACK_HEIGHT}px` }}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] text-muted-foreground select-none">
|
|
||||||
{trackNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable timeline area */}
|
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
|
||||||
<div
|
|
||||||
ref={tracksRef}
|
|
||||||
className="overflow-auto relative flex-1"
|
|
||||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
|
||||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
|
||||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
|
||||||
>
|
>
|
||||||
{/* Time ruler - clickable to seek */}
|
<GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
|
||||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
{/* Toolbar */}
|
||||||
style={{ width: `${timelineWidth}px` }}
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
|
||||||
onClick={handleTimelineClick}
|
{/* Play controls - left side */}
|
||||||
aria-label="Seek timeline"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
|
||||||
{timeMarkers.map((ms) => (
|
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
<div
|
</Button>
|
||||||
key={ms}
|
<Button
|
||||||
className="absolute top-0 h-full flex flex-col justify-end pointer-events-none"
|
variant="ghost"
|
||||||
style={{ left: `${msToPixels(ms)}px` }}
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={!isCurrentlyPlaying}
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
||||||
|
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clip editing controls - center */}
|
||||||
|
{selectedClipId && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleSplit}
|
||||||
|
title="Split at playhead (S)"
|
||||||
>
|
>
|
||||||
<div className="h-2 w-px bg-border" />
|
<Scissors className="h-4 w-4" />
|
||||||
<span className="text-[10px] text-muted-foreground ml-1 select-none">
|
</Button>
|
||||||
{formatTime(ms)}
|
<Button
|
||||||
</span>
|
variant="ghost"
|
||||||
</div>
|
size="icon"
|
||||||
))}
|
className="h-7 w-7"
|
||||||
</button>
|
onClick={handleDuplicate}
|
||||||
|
title="Duplicate (Cmd/Ctrl+D)"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleDelete}
|
||||||
|
title="Delete (Delete/Backspace)"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tracks area */}
|
{/* Zoom controls - right side */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline container with track labels sidebar */}
|
||||||
|
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
|
||||||
|
{/* Track labels sidebar - fixed width */}
|
||||||
|
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
|
||||||
|
{/* Spacer for time ruler */}
|
||||||
|
<div className="h-6 border-b bg-muted/30" />
|
||||||
|
{/* Track labels */}
|
||||||
|
<div style={{ height: `${tracksAreaHeight}px` }}>
|
||||||
|
{tracks.map((trackNumber, index) => (
|
||||||
|
<div
|
||||||
|
key={trackNumber}
|
||||||
|
className={cn(
|
||||||
|
'border-b flex items-center justify-center',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||||
|
)}
|
||||||
|
style={{ height: `${TRACK_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-muted-foreground select-none">
|
||||||
|
{trackNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable timeline area */}
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||||
<div
|
<div
|
||||||
className="relative"
|
ref={tracksRef}
|
||||||
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
className="overflow-auto relative flex-1"
|
||||||
|
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||||
|
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||||
|
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
|
{/* Time ruler - clickable to seek */}
|
||||||
{tracks.map((trackNumber, index) => (
|
|
||||||
<div
|
|
||||||
key={trackNumber}
|
|
||||||
className={cn(
|
|
||||||
'absolute left-0 right-0 border-b pointer-events-none',
|
|
||||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
top: `${index * TRACK_HEIGHT}px`,
|
|
||||||
height: `${TRACK_HEIGHT}px`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Click area for seeking - z-index lower than clips */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-0 z-0 cursor-pointer"
|
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||||
|
style={{ width: `${timelineWidth}px` }}
|
||||||
onClick={handleTimelineClick}
|
onClick={handleTimelineClick}
|
||||||
aria-label="Seek timeline"
|
aria-label="Seek timeline"
|
||||||
/>
|
>
|
||||||
|
{timeMarkers.map((ms) => (
|
||||||
{/* Audio clips */}
|
<div
|
||||||
{items.map((item) => {
|
key={ms}
|
||||||
const isDragging = draggingItem === item.id;
|
className="absolute top-0 h-full flex flex-col justify-end pointer-events-none"
|
||||||
const isSelected = selectedClipId === item.id;
|
style={{ left: `${msToPixels(ms)}px` }}
|
||||||
const isTrimming = trimmingItem === item.id;
|
|
||||||
|
|
||||||
// Use temporary trim values during trimming for visual feedback
|
|
||||||
const displayTrimStart = isTrimming && tempTrimValues ? tempTrimValues.trim_start_ms : (item.trim_start_ms || 0);
|
|
||||||
const displayTrimEnd = isTrimming && tempTrimValues ? tempTrimValues.trim_end_ms : (item.trim_end_ms || 0);
|
|
||||||
const effectiveDuration = (item.duration * 1000) - displayTrimStart - displayTrimEnd;
|
|
||||||
|
|
||||||
const style = getClipStyle({ ...item, trim_start_ms: displayTrimStart, trim_end_ms: displayTrimEnd });
|
|
||||||
const clipWidth = msToPixels(effectiveDuration);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={cn(
|
|
||||||
'absolute rounded select-none overflow-visible z-10',
|
|
||||||
isSelected && 'ring-2 ring-primary ring-offset-1',
|
|
||||||
isTrimming && 'ring-2 ring-accent'
|
|
||||||
)}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'w-full h-full rounded cursor-move overflow-hidden',
|
|
||||||
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
|
|
||||||
'flex flex-col justify-center',
|
|
||||||
isDragging && 'opacity-80 shadow-lg z-20',
|
|
||||||
!isDragging && 'transition-all duration-100'
|
|
||||||
)}
|
|
||||||
onClick={(e) => handleClipClick(e, item)}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
// Only start drag if not clicking on trim handles
|
|
||||||
if (!(e.target as HTMLElement).closest('.trim-handle')) {
|
|
||||||
handleDragStart(e, item);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Clip label */}
|
<div className="h-2 w-px bg-border" />
|
||||||
<div className="absolute top-0 left-1 right-1 z-10">
|
<span className="text-[10px] text-muted-foreground ml-1 select-none">
|
||||||
<p className="text-[9px] font-medium text-accent-foreground truncate">
|
{formatTime(ms)}
|
||||||
{item.profile_name}
|
</span>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
))}
|
||||||
{/* Waveform */}
|
</button>
|
||||||
<div className="absolute inset-0 top-3">
|
|
||||||
<ClipWaveform
|
|
||||||
generationId={item.generation_id}
|
|
||||||
width={clipWidth}
|
|
||||||
trimStartMs={displayTrimStart}
|
|
||||||
trimEndMs={displayTrimEnd}
|
|
||||||
duration={item.duration}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Trim handles */}
|
|
||||||
{isSelected && (
|
|
||||||
<>
|
|
||||||
{/* Left trim handle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="trim-handle absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-l"
|
|
||||||
onMouseDown={(e) => handleTrimStart(e, item, 'start')}
|
|
||||||
aria-label="Trim start"
|
|
||||||
/>
|
|
||||||
{/* Right trim handle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="trim-handle absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-r"
|
|
||||||
onMouseDown={(e) => handleTrimStart(e, item, 'end')}
|
|
||||||
aria-label="Trim end"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Playhead - always visible */}
|
{/* Tracks area */}
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 w-1 bg-accent z-30 pointer-events-none rounded-full"
|
className="relative"
|
||||||
style={{ left: `${playheadLeft}px` }}
|
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-accent rounded-full" />
|
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
|
||||||
|
{tracks.map((trackNumber, index) => (
|
||||||
|
<div
|
||||||
|
key={trackNumber}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 right-0 border-b pointer-events-none',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${index * TRACK_HEIGHT}px`,
|
||||||
|
height: `${TRACK_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Click area for seeking - z-index lower than clips */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 z-0 cursor-pointer"
|
||||||
|
onClick={handleTimelineClick}
|
||||||
|
aria-label="Seek timeline"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Audio clips */}
|
||||||
|
{items.map((item) => {
|
||||||
|
const isDragging = draggingItem === item.id;
|
||||||
|
const isSelected = selectedClipId === item.id;
|
||||||
|
const isTrimming = trimmingItem === item.id;
|
||||||
|
|
||||||
|
// Use temporary trim values during trimming for visual feedback
|
||||||
|
const displayTrimStart =
|
||||||
|
isTrimming && tempTrimValues
|
||||||
|
? tempTrimValues.trim_start_ms
|
||||||
|
: item.trim_start_ms || 0;
|
||||||
|
const displayTrimEnd =
|
||||||
|
isTrimming && tempTrimValues ? tempTrimValues.trim_end_ms : item.trim_end_ms || 0;
|
||||||
|
const effectiveDuration = item.duration * 1000 - displayTrimStart - displayTrimEnd;
|
||||||
|
|
||||||
|
const style = getClipStyle({
|
||||||
|
...item,
|
||||||
|
trim_start_ms: displayTrimStart,
|
||||||
|
trim_end_ms: displayTrimEnd,
|
||||||
|
});
|
||||||
|
const clipWidth = msToPixels(effectiveDuration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
'absolute rounded select-none overflow-visible z-10',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-1',
|
||||||
|
isTrimming && 'ring-2 ring-accent',
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full rounded cursor-move overflow-hidden',
|
||||||
|
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
|
||||||
|
'flex flex-col justify-center',
|
||||||
|
isDragging && 'opacity-80 shadow-lg z-20',
|
||||||
|
!isDragging && 'transition-all duration-100',
|
||||||
|
)}
|
||||||
|
onClick={(e) => handleClipClick(e, item)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Only start drag if not clicking on trim handles
|
||||||
|
if (!(e.target as HTMLElement).closest('.trim-handle')) {
|
||||||
|
handleDragStart(e, item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Clip label */}
|
||||||
|
<div className="absolute top-0 left-1 right-1 z-10">
|
||||||
|
<p className="text-[9px] font-medium text-accent-foreground truncate">
|
||||||
|
{item.profile_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Waveform */}
|
||||||
|
<div className="absolute inset-0 top-3">
|
||||||
|
<ClipWaveform
|
||||||
|
generationId={item.generation_id}
|
||||||
|
width={clipWidth}
|
||||||
|
trimStartMs={displayTrimStart}
|
||||||
|
trimEndMs={displayTrimEnd}
|
||||||
|
duration={item.duration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Trim handles */}
|
||||||
|
{isSelected && (
|
||||||
|
<>
|
||||||
|
{/* Left trim handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="trim-handle absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-l"
|
||||||
|
onMouseDown={(e) => handleTrimStart(e, item, 'start')}
|
||||||
|
aria-label="Trim start"
|
||||||
|
/>
|
||||||
|
{/* Right trim handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="trim-handle absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-r"
|
||||||
|
onMouseDown={(e) => handleTrimStart(e, item, 'end')}
|
||||||
|
aria-label="Trim end"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Playhead - always visible */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-1 bg-accent z-30 pointer-events-none rounded-full"
|
||||||
|
style={{ left: `${playheadLeft}px` }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,14 +58,11 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
// Calculate total duration from items
|
// Calculate total duration from items
|
||||||
const maxEndTimeMs = Math.max(
|
const maxEndTimeMs = Math.max(
|
||||||
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find the minimum start time (first item)
|
// Find the minimum start time (first item)
|
||||||
const minStartTimeMs = Math.min(
|
const minStartTimeMs = Math.min(...items.map((item) => item.start_time_ms), 0);
|
||||||
...items.map((item) => item.start_time_ms),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
// If resuming the same story, keep position; otherwise start at first item
|
// If resuming the same story, keep position; otherwise start at first item
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
@@ -75,7 +72,11 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
console.log('[StoryStore] Play called:', {
|
console.log('[StoryStore] Play called:', {
|
||||||
storyId,
|
storyId,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
items: items.map(i => ({ id: i.generation_id, start: i.start_time_ms, duration: i.duration })),
|
items: items.map((i) => ({
|
||||||
|
id: i.generation_id,
|
||||||
|
start: i.start_time_ms,
|
||||||
|
duration: i.duration,
|
||||||
|
})),
|
||||||
maxEndTimeMs,
|
maxEndTimeMs,
|
||||||
minStartTimeMs,
|
minStartTimeMs,
|
||||||
startTimeMs,
|
startTimeMs,
|
||||||
@@ -92,7 +93,7 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
pause: () => {
|
pause: () => {
|
||||||
set({
|
set({
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
// Keep timing anchors so we can resume from same position
|
// Keep timing anchors so we can resume from same position
|
||||||
});
|
});
|
||||||
@@ -113,7 +114,7 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
seek: (timeMs) => {
|
seek: (timeMs) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const clampedTime = Math.max(0, Math.min(timeMs, state.totalDurationMs));
|
const clampedTime = Math.max(0, Math.min(timeMs, state.totalDurationMs));
|
||||||
set({
|
set({
|
||||||
currentTimeMs: clampedTime,
|
currentTimeMs: clampedTime,
|
||||||
// Reset timing anchors - will be set by hook when playback resumes
|
// Reset timing anchors - will be set by hook when playback resumes
|
||||||
playbackStartContextTime: null,
|
playbackStartContextTime: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user