mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 23:00:45 -07:00
Enhance story item management with track editing functionality
- Introduced StoryTrackEditor component for managing story item positions and tracks. - Updated StoriesTab to conditionally render the track editor based on selected story. - Implemented moveStoryItem API endpoint to handle item repositioning and track changes. - Enhanced story item data model to include track information. - Improved audio playback management to support multiple tracks using Web Audio API. - Added hooks for moving story items and managing playback timing.
This commit is contained in:
@@ -1,25 +1,42 @@
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { StoryContent } from './StoryContent';
|
||||
import { StoryList } from './StoryList';
|
||||
import { StoryTrackEditor } from './StoryTrackEditor';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useStory } from '@/lib/hooks/useStories';
|
||||
|
||||
export function StoriesTab() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story } = useStory(selectedStoryId);
|
||||
|
||||
const hasTrackEditor = selectedStoryId && story && story.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
||||
{/* Left Column - Story List */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<StoryList />
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-2 gap-6 overflow-hidden relative">
|
||||
{/* Left Column - Story List */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<StoryList />
|
||||
</div>
|
||||
|
||||
{/* Right Column - Story Content */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box */}
|
||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl || !!hasTrackEditor} showVoiceSelector />
|
||||
</div>
|
||||
|
||||
{/* Right Column - Story Content */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box */}
|
||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl} showVoiceSelector />
|
||||
{/* Track Editor - at bottom when a story with items is selected */}
|
||||
{hasTrackEditor && (
|
||||
<div className="shrink-0 mt-4 px-1">
|
||||
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { GripHorizontal, Minus, Plus } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useMoveStoryItem } from '@/lib/hooks/useStories';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface StoryTrackEditorProps {
|
||||
storyId: string;
|
||||
items: StoryItemDetail[];
|
||||
}
|
||||
|
||||
const TRACK_HEIGHT = 48;
|
||||
const MIN_PIXELS_PER_SECOND = 10;
|
||||
const MAX_PIXELS_PER_SECOND = 200;
|
||||
const DEFAULT_PIXELS_PER_SECOND = 50;
|
||||
const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks
|
||||
const MIN_EDITOR_HEIGHT = 120;
|
||||
const MAX_EDITOR_HEIGHT = 500;
|
||||
const DEFAULT_EDITOR_HEIGHT = 200;
|
||||
|
||||
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND);
|
||||
const [draggingItem, setDraggingItem] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const [editorHeight, setEditorHeight] = useState(DEFAULT_EDITOR_HEIGHT);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tracksRef = useRef<HTMLDivElement>(null);
|
||||
const resizeStartY = useRef(0);
|
||||
const resizeStartHeight = useRef(0);
|
||||
const moveItem = useMoveStoryItem();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Playback state
|
||||
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||
const seek = useStoryStore((state) => state.seek);
|
||||
|
||||
const isActiveStory = playbackStoryId === storyId;
|
||||
|
||||
// Calculate unique tracks from items, always showing at least 3 default tracks
|
||||
const tracks = useMemo(() => {
|
||||
const trackSet = new Set([...DEFAULT_TRACKS, ...items.map((item) => item.track)]);
|
||||
return Array.from(trackSet).sort((a, b) => b - a); // Higher tracks on top
|
||||
}, [items]);
|
||||
|
||||
// Calculate total duration
|
||||
const totalDurationMs = useMemo(() => {
|
||||
if (items.length === 0) return 10000; // Default 10 seconds
|
||||
return Math.max(
|
||||
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
||||
10000
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
// Calculate timeline width
|
||||
const timelineWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Extra padding
|
||||
|
||||
// Generate time markers
|
||||
const timeMarkers = useMemo(() => {
|
||||
const markers: number[] = [];
|
||||
// Determine interval based on zoom level
|
||||
let intervalMs = 5000; // 5 seconds
|
||||
if (pixelsPerSecond > 100) intervalMs = 1000;
|
||||
else if (pixelsPerSecond > 50) intervalMs = 2000;
|
||||
else if (pixelsPerSecond < 20) intervalMs = 10000;
|
||||
|
||||
for (let ms = 0; ms <= totalDurationMs + intervalMs; ms += intervalMs) {
|
||||
markers.push(ms);
|
||||
}
|
||||
return markers;
|
||||
}, [totalDurationMs, pixelsPerSecond]);
|
||||
|
||||
const formatTime = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const msToPixels = useCallback(
|
||||
(ms: number) => (ms / 1000) * pixelsPerSecond,
|
||||
[pixelsPerSecond]
|
||||
);
|
||||
|
||||
const pixelsToMs = useCallback(
|
||||
(px: number) => (px / pixelsPerSecond) * 1000,
|
||||
[pixelsPerSecond]
|
||||
);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setPixelsPerSecond((prev) => Math.max(prev / 1.5, MIN_PIXELS_PER_SECOND));
|
||||
};
|
||||
|
||||
// Resize handlers
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
resizeStartY.current = e.clientY;
|
||||
resizeStartHeight.current = editorHeight;
|
||||
}, [editorHeight]);
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
const deltaY = resizeStartY.current - e.clientY;
|
||||
const newHeight = Math.min(
|
||||
MAX_EDITOR_HEIGHT,
|
||||
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY)
|
||||
);
|
||||
setEditorHeight(newHeight);
|
||||
}, [isResizing]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
|
||||
// Add global mouse listeners for resizing
|
||||
useEffect(() => {
|
||||
if (isResizing) {
|
||||
window.addEventListener('mousemove', handleResizeMove);
|
||||
window.addEventListener('mouseup', handleResizeEnd);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleResizeMove);
|
||||
window.removeEventListener('mouseup', handleResizeEnd);
|
||||
};
|
||||
}
|
||||
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
||||
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!tracksRef.current || draggingItem) return;
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
|
||||
const timeMs = Math.max(0, pixelsToMs(x));
|
||||
seek(timeMs);
|
||||
};
|
||||
|
||||
const handleDragStart = (
|
||||
e: React.MouseEvent,
|
||||
item: StoryItemDetail
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
if (!tracksRef.current) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
setDragPosition({
|
||||
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
||||
y: rect.top - tracksRef.current.getBoundingClientRect().top,
|
||||
});
|
||||
setDraggingItem(item.generation_id);
|
||||
};
|
||||
|
||||
const handleDragMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!draggingItem || !tracksRef.current) return;
|
||||
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
|
||||
const y = e.clientY - rect.top - dragOffset.y;
|
||||
|
||||
setDragPosition({ x: Math.max(0, x), y });
|
||||
},
|
||||
[draggingItem, dragOffset]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (!draggingItem || !tracksRef.current) {
|
||||
setDraggingItem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = items.find((i) => i.generation_id === draggingItem);
|
||||
if (!item) {
|
||||
setDraggingItem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new time from x position
|
||||
const newTimeMs = Math.max(0, Math.round(pixelsToMs(dragPosition.x)));
|
||||
|
||||
// Calculate new track from y position
|
||||
const trackIndex = Math.floor(dragPosition.y / TRACK_HEIGHT);
|
||||
const clampedTrackIndex = Math.max(0, Math.min(trackIndex, tracks.length - 1));
|
||||
const newTrack = tracks[clampedTrackIndex] ?? 0;
|
||||
|
||||
// Check if position changed
|
||||
if (newTimeMs !== item.start_time_ms || newTrack !== item.track) {
|
||||
moveItem.mutate(
|
||||
{
|
||||
storyId,
|
||||
generationId: item.generation_id,
|
||||
data: {
|
||||
start_time_ms: newTimeMs,
|
||||
track: newTrack,
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to move item',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setDraggingItem(null);
|
||||
}, [draggingItem, dragPosition, items, tracks, pixelsToMs, storyId, moveItem, toast]);
|
||||
|
||||
// Get track index for rendering
|
||||
const getTrackIndex = (trackNumber: number) => tracks.indexOf(trackNumber);
|
||||
|
||||
// Calculate clip position and dimensions
|
||||
const getClipStyle = (item: StoryItemDetail) => {
|
||||
const isDragging = draggingItem === item.generation_id;
|
||||
const trackIndex = getTrackIndex(item.track);
|
||||
const width = msToPixels(item.duration * 1000);
|
||||
const left = isDragging ? dragPosition.x : msToPixels(item.start_time_ms);
|
||||
const top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT;
|
||||
|
||||
return {
|
||||
width: `${width}px`,
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
height: `${TRACK_HEIGHT - 4}px`,
|
||||
};
|
||||
};
|
||||
|
||||
// Playhead position
|
||||
const playheadLeft = msToPixels(currentTimeMs);
|
||||
|
||||
// Calculate tracks area height
|
||||
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
|
||||
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="h-[200px] border rounded-lg bg-card flex items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">Add audio clips to see the track editor</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card overflow-hidden relative" ref={containerRef}>
|
||||
{/* Resize handle at top */}
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/30 mt-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>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{Math.round(pixelsPerSecond)}px/s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline container - drag handlers are intentional for drag-and-drop UX */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||
<div
|
||||
ref={tracksRef}
|
||||
className="overflow-auto relative"
|
||||
style={{ height: `${timelineContainerHeight}px` }}
|
||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||
>
|
||||
{/* Time ruler */}
|
||||
<div
|
||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10"
|
||||
style={{ width: `${timelineWidth}px` }}
|
||||
>
|
||||
{timeMarkers.map((ms) => (
|
||||
<div
|
||||
key={ms}
|
||||
className="absolute top-0 h-full flex flex-col justify-end"
|
||||
style={{ left: `${msToPixels(ms)}px` }}
|
||||
>
|
||||
<div className="h-2 w-px bg-border" />
|
||||
<span className="text-[10px] text-muted-foreground ml-1 select-none">
|
||||
{formatTime(ms)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tracks area */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
||||
>
|
||||
{/* Track backgrounds */}
|
||||
{tracks.map((trackNumber, index) => (
|
||||
<div
|
||||
key={trackNumber}
|
||||
className={cn(
|
||||
'absolute left-0 right-0 border-b',
|
||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
||||
)}
|
||||
style={{
|
||||
top: `${index * TRACK_HEIGHT}px`,
|
||||
height: `${TRACK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground select-none">
|
||||
Track {trackNumber}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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.generation_id;
|
||||
const style = getClipStyle(item);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={item.generation_id}
|
||||
className={cn(
|
||||
'absolute rounded cursor-move select-none overflow-hidden z-10',
|
||||
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
|
||||
'flex items-center px-2 text-left',
|
||||
isDragging && 'opacity-80 shadow-lg z-20',
|
||||
!isDragging && 'transition-all duration-100'
|
||||
)}
|
||||
style={style}
|
||||
onMouseDown={(e) => handleDragStart(e, item)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-medium text-accent-foreground truncate">
|
||||
{item.profile_name}
|
||||
</p>
|
||||
<p className="text-[9px] text-accent-foreground/70 truncate">
|
||||
{item.text.substring(0, 30)}...
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Playhead */}
|
||||
{isActiveStory && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-primary z-30 pointer-events-none"
|
||||
style={{ left: `${playheadLeft}px` }}
|
||||
>
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user