mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -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 { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
import { StoryContent } from './StoryContent';
|
import { StoryContent } from './StoryContent';
|
||||||
import { StoryList } from './StoryList';
|
import { StoryList } from './StoryList';
|
||||||
|
import { StoryTrackEditor } from './StoryTrackEditor';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
|
|
||||||
export function StoriesTab() {
|
export function StoriesTab() {
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
{/* Left Column - Story List */}
|
{/* Main content area */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-2 gap-6 overflow-hidden relative">
|
||||||
<StoryList />
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Story Content */}
|
{/* Track Editor - at bottom when a story with items is selected */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
{hasTrackEditor && (
|
||||||
<StoryContent />
|
<div className="shrink-0 mt-4 px-1">
|
||||||
</div>
|
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||||
|
</div>
|
||||||
{/* Floating Generate Box */}
|
)}
|
||||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl} showVoiceSelector />
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
StoryItemDetail,
|
StoryItemDetail,
|
||||||
StoryItemBatchUpdate,
|
StoryItemBatchUpdate,
|
||||||
StoryItemReorder,
|
StoryItemReorder,
|
||||||
|
StoryItemMove,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -425,6 +426,13 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async moveStoryItem(storyId: string, generationId: string, data: StoryItemMove): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${generationId}/move`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async exportStoryAudio(storyId: string): Promise<Blob> {
|
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||||
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export interface StoryItemDetail {
|
|||||||
story_id: string;
|
story_id: string;
|
||||||
generation_id: string;
|
generation_id: string;
|
||||||
start_time_ms: number;
|
start_time_ms: number;
|
||||||
|
track: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
@@ -167,6 +168,7 @@ export interface StoryDetailResponse {
|
|||||||
export interface StoryItemCreate {
|
export interface StoryItemCreate {
|
||||||
generation_id: string;
|
generation_id: string;
|
||||||
start_time_ms?: number;
|
start_time_ms?: number;
|
||||||
|
track?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoryItemUpdateTime {
|
export interface StoryItemUpdateTime {
|
||||||
@@ -181,3 +183,8 @@ export interface StoryItemBatchUpdate {
|
|||||||
export interface StoryItemReorder {
|
export interface StoryItemReorder {
|
||||||
generation_ids: string[];
|
generation_ids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoryItemMove {
|
||||||
|
start_time_ms: number;
|
||||||
|
track: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder } from '@/lib/api/types';
|
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove } from '@/lib/api/types';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
|
||||||
export function useStories() {
|
export function useStories() {
|
||||||
@@ -105,6 +105,19 @@ export function useReorderStoryItems() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMoveStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, generationId, data }: { storyId: string; generationId: string; data: StoryItemMove }) =>
|
||||||
|
apiClient.moveStoryItem(storyId, generationId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useExportStoryAudio() {
|
export function useExportStoryAudio() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
||||||
|
|||||||
@@ -1,77 +1,274 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
|
interface ActiveSource {
|
||||||
|
source: AudioBufferSourceNode;
|
||||||
|
generationId: string;
|
||||||
|
startTimeMs: number;
|
||||||
|
endTimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing timecode-based story playback.
|
* Hook for managing timecode-based story playback using Web Audio API.
|
||||||
* Uses a single audio element for reliable playback.
|
* Supports multiple simultaneous audio sources for overlapping clips on different tracks.
|
||||||
|
* Uses AudioContext for sample-accurate timing synchronization.
|
||||||
*/
|
*/
|
||||||
export function useStoryPlayback(_items: StoryItemDetail[] | undefined) {
|
export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||||
const isPlaying = useStoryStore((state) => state.isPlaying);
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
const playbackItems = useStoryStore((state) => state.playbackItems);
|
const playbackItems = useStoryStore((state) => state.playbackItems);
|
||||||
const tick = useStoryStore((state) => state.tick);
|
const playbackStartContextTime = useStoryStore((state) => state.playbackStartContextTime);
|
||||||
|
const playbackStartStoryTime = useStoryStore((state) => state.playbackStartStoryTime);
|
||||||
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
|
const setPlaybackTiming = useStoryStore((state) => state.setPlaybackTiming);
|
||||||
|
|
||||||
// Single audio element for playback
|
// AudioContext instance (created once)
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
const currentItemIdRef = useRef<string | null>(null);
|
// Master gain for volume control
|
||||||
|
const masterGainRef = useRef<GainNode | null>(null);
|
||||||
|
// Preloaded AudioBuffers by generation_id
|
||||||
|
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
|
||||||
|
// Currently playing AudioBufferSourceNodes by generation_id
|
||||||
|
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
|
||||||
|
// Animation frame for syncing visual playhead
|
||||||
const animationFrameRef = useRef<number | null>(null);
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
const lastTimeRef = useRef<number>(Date.now());
|
|
||||||
|
|
||||||
// Get or create audio element
|
// Get or create AudioContext and audio graph
|
||||||
const getAudio = useCallback(() => {
|
const getAudioContext = useCallback(() => {
|
||||||
if (!audioRef.current) {
|
if (!audioContextRef.current) {
|
||||||
audioRef.current = new Audio();
|
audioContextRef.current = new AudioContext();
|
||||||
audioRef.current.preload = 'auto';
|
console.log(
|
||||||
|
'[StoryPlayback] Created AudioContext, sample rate:',
|
||||||
|
audioContextRef.current.sampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create master gain node for volume control
|
||||||
|
// Set to 0.5 to prevent distortion from overlapping audio
|
||||||
|
masterGainRef.current = audioContextRef.current.createGain();
|
||||||
|
masterGainRef.current.gain.value = 0.05;
|
||||||
|
masterGainRef.current.connect(audioContextRef.current.destination);
|
||||||
}
|
}
|
||||||
return audioRef.current;
|
// Resume context if suspended (browser autoplay policy)
|
||||||
|
if (audioContextRef.current.state === 'suspended') {
|
||||||
|
audioContextRef.current.resume().catch(() => {
|
||||||
|
// Ignore resume errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return audioContextRef.current;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Find the item that should be playing at a given time
|
// Stop a source
|
||||||
const findActiveItem = useCallback((timeMs: number, items: StoryItemDetail[]): StoryItemDetail | null => {
|
const stopSource = useCallback((generationId: string) => {
|
||||||
|
const activeSource = activeSourcesRef.current.get(generationId);
|
||||||
|
if (activeSource) {
|
||||||
|
try {
|
||||||
|
activeSource.source.stop();
|
||||||
|
} catch {
|
||||||
|
// Source may have already stopped
|
||||||
|
}
|
||||||
|
activeSourcesRef.current.delete(generationId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Preload audio files as AudioBuffers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
// Clear preloaded buffers when no items
|
||||||
|
audioBuffersRef.current.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIds = new Set(items.map((item) => item.generation_id));
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
|
||||||
|
// Remove buffers for items that no longer exist
|
||||||
|
for (const [id] of audioBuffersRef.current) {
|
||||||
|
if (!currentIds.has(id)) {
|
||||||
|
audioBuffersRef.current.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload audio for new items
|
||||||
|
const preloadPromises: Promise<void>[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const itemStart = item.start_time_ms;
|
if (!audioBuffersRef.current.has(item.generation_id)) {
|
||||||
const itemEnd = item.start_time_ms + item.duration * 1000;
|
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
||||||
if (timeMs >= itemStart && timeMs < itemEnd) {
|
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
|
||||||
return item;
|
|
||||||
|
const preloadPromise = fetch(audioUrl)
|
||||||
|
.then((response) => response.arrayBuffer())
|
||||||
|
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||||
|
.then((audioBuffer) => {
|
||||||
|
audioBuffersRef.current.set(item.generation_id, audioBuffer);
|
||||||
|
console.log(
|
||||||
|
'[StoryPlayback] Preloaded buffer:',
|
||||||
|
item.generation_id,
|
||||||
|
'duration:',
|
||||||
|
audioBuffer.duration,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
preloadPromises.push(preloadPromise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Find the next item after a given time
|
Promise.all(preloadPromises).then(() => {
|
||||||
const findNextItem = useCallback((timeMs: number, items: StoryItemDetail[]): StoryItemDetail | null => {
|
console.log('[StoryPlayback] Preloaded', audioBuffersRef.current.size, 'audio buffers');
|
||||||
const sorted = [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
});
|
||||||
for (const item of sorted) {
|
}, [items, getAudioContext]);
|
||||||
if (item.start_time_ms > timeMs) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup AudioContext on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (audioRef.current) {
|
// Stop all sources
|
||||||
audioRef.current.pause();
|
for (const [generationId] of activeSourcesRef.current) {
|
||||||
audioRef.current.src = '';
|
stopSource(generationId);
|
||||||
}
|
}
|
||||||
|
activeSourcesRef.current.clear();
|
||||||
|
|
||||||
|
// Clean up audio graph
|
||||||
|
if (masterGainRef.current) {
|
||||||
|
masterGainRef.current.disconnect();
|
||||||
|
masterGainRef.current = null;
|
||||||
|
}
|
||||||
|
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||||
|
audioContextRef.current.close().catch(() => {
|
||||||
|
// Ignore errors when closing
|
||||||
|
});
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (animationFrameRef.current !== null) {
|
if (animationFrameRef.current !== null) {
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [stopSource]);
|
||||||
|
|
||||||
// Main playback effect
|
// Find ALL items that should be playing at a given story time
|
||||||
|
const findActiveItems = useCallback(
|
||||||
|
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
|
||||||
|
return itemList.filter((item) => {
|
||||||
|
const itemStart = item.start_time_ms;
|
||||||
|
const itemEnd = item.start_time_ms + item.duration * 1000;
|
||||||
|
return storyTimeMs >= itemStart && storyTimeMs < itemEnd;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert AudioContext time to story time (ms)
|
||||||
|
const contextTimeToStoryTime = useCallback(
|
||||||
|
(contextTime: number): number => {
|
||||||
|
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsedContextTime = contextTime - playbackStartContextTime;
|
||||||
|
return playbackStartStoryTime + elapsedContextTime * 1000;
|
||||||
|
},
|
||||||
|
[playbackStartContextTime, playbackStartStoryTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert story time (ms) to AudioContext time
|
||||||
|
const storyTimeToContextTime = useCallback(
|
||||||
|
(storyTimeMs: number): number => {
|
||||||
|
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsedStoryTime = (storyTimeMs - playbackStartStoryTime) / 1000;
|
||||||
|
return playbackStartContextTime + elapsedStoryTime;
|
||||||
|
},
|
||||||
|
[playbackStartContextTime, playbackStartStoryTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop all sources
|
||||||
|
const stopAllSources = useCallback(() => {
|
||||||
|
console.log('[StoryPlayback] Stopping all sources');
|
||||||
|
for (const [generationId] of activeSourcesRef.current) {
|
||||||
|
stopSource(generationId);
|
||||||
|
}
|
||||||
|
activeSourcesRef.current.clear();
|
||||||
|
}, [stopSource]);
|
||||||
|
|
||||||
|
// Schedule playback for all items that should be playing
|
||||||
|
const schedulePlayback = useCallback(
|
||||||
|
(storyTimeMs: number, itemList: StoryItemDetail[]) => {
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
|
||||||
|
// Find all items that should be playing
|
||||||
|
const shouldBePlaying = findActiveItems(storyTimeMs, itemList);
|
||||||
|
const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.generation_id));
|
||||||
|
|
||||||
|
// Stop sources that shouldn't be playing anymore
|
||||||
|
for (const [generationId] of activeSourcesRef.current) {
|
||||||
|
if (!shouldBePlayingIds.has(generationId)) {
|
||||||
|
stopSource(generationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new sources for items that should be playing
|
||||||
|
for (const item of shouldBePlaying) {
|
||||||
|
if (!activeSourcesRef.current.has(item.generation_id)) {
|
||||||
|
const buffer = audioBuffersRef.current.get(item.generation_id);
|
||||||
|
if (!buffer) {
|
||||||
|
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate when this item should start in AudioContext time
|
||||||
|
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||||
|
const itemEndStoryTime = item.start_time_ms + item.duration * 1000;
|
||||||
|
|
||||||
|
// Calculate offset into the buffer (if seeking mid-way)
|
||||||
|
const offsetIntoBuffer = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000);
|
||||||
|
const duration = item.duration - offsetIntoBuffer;
|
||||||
|
|
||||||
|
// If the item should have already started, schedule it to start immediately
|
||||||
|
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
|
||||||
|
|
||||||
|
console.log('[StoryPlayback] Scheduling source:', {
|
||||||
|
generationId: item.generation_id,
|
||||||
|
storyTimeMs,
|
||||||
|
itemStart: item.start_time_ms,
|
||||||
|
offsetIntoBuffer,
|
||||||
|
startAtContextTime,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(masterGainRef.current || audioContext.destination);
|
||||||
|
|
||||||
|
const activeSource: ActiveSource = {
|
||||||
|
source,
|
||||||
|
generationId: item.generation_id,
|
||||||
|
startTimeMs: item.start_time_ms,
|
||||||
|
endTimeMs: itemEndStoryTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
activeSourcesRef.current.set(item.generation_id, activeSource);
|
||||||
|
|
||||||
|
// Schedule playback
|
||||||
|
source.start(startAtContextTime, offsetIntoBuffer, duration);
|
||||||
|
|
||||||
|
// Clean up when source ends
|
||||||
|
source.onended = () => {
|
||||||
|
console.log('[StoryPlayback] Source ended:', item.generation_id);
|
||||||
|
activeSourcesRef.current.delete(item.generation_id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getAudioContext, findActiveItems, storyTimeToContextTime, stopSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync visual playhead from AudioContext time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = getAudio();
|
if (!isPlaying || playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
|
||||||
if (!isPlaying || !playbackItems || playbackItems.length === 0) {
|
|
||||||
console.log('[StoryPlayback] Stopping playback');
|
|
||||||
audio.pause();
|
|
||||||
currentItemIdRef.current = null;
|
|
||||||
|
|
||||||
if (animationFrameRef.current !== null) {
|
if (animationFrameRef.current !== null) {
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
animationFrameRef.current = null;
|
animationFrameRef.current = null;
|
||||||
@@ -79,115 +276,97 @@ export function useStoryPlayback(_items: StoryItemDetail[] | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = playbackItems; // Capture for closure
|
const audioContext = getAudioContext();
|
||||||
console.log('[StoryPlayback] Starting playback');
|
const itemList = playbackItems || [];
|
||||||
|
|
||||||
const playItem = (item: StoryItemDetail, offsetMs: number = 0) => {
|
const syncPlayhead = () => {
|
||||||
console.log('[StoryPlayback] Playing item:', item.generation_id, 'offset:', offsetMs);
|
if (!useStoryStore.getState().isPlaying) {
|
||||||
currentItemIdRef.current = item.generation_id;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
const currentContextTime = audioContext.currentTime;
|
||||||
audio.src = audioUrl;
|
const currentStoryTime = contextTimeToStoryTime(currentContextTime);
|
||||||
|
|
||||||
audio.onloadedmetadata = () => {
|
|
||||||
const offsetSeconds = Math.max(0, offsetMs / 1000);
|
|
||||||
audio.currentTime = offsetSeconds;
|
|
||||||
audio.play().catch(err => {
|
|
||||||
console.error('[StoryPlayback] Play failed:', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
audio.onerror = (e) => {
|
|
||||||
console.error('[StoryPlayback] Audio error:', e);
|
|
||||||
};
|
|
||||||
|
|
||||||
// When this audio ends, advance the clock and check for next item
|
|
||||||
audio.onended = () => {
|
|
||||||
console.log('[StoryPlayback] Audio ended');
|
|
||||||
const state = useStoryStore.getState();
|
|
||||||
if (!state.isPlaying || !state.playbackItems) return;
|
|
||||||
|
|
||||||
// Find what's next
|
|
||||||
const nextItem = findNextItem(state.currentTimeMs, state.playbackItems);
|
|
||||||
if (nextItem) {
|
|
||||||
// Jump to next item's start
|
|
||||||
useStoryStore.setState({ currentTimeMs: nextItem.start_time_ms });
|
|
||||||
playItem(nextItem, 0);
|
|
||||||
} else {
|
|
||||||
// No more items
|
|
||||||
console.log('[StoryPlayback] Story complete');
|
|
||||||
useStoryStore.getState().stop();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Animation frame for updating the clock
|
|
||||||
const updateClock = () => {
|
|
||||||
if (!useStoryStore.getState().isPlaying) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const deltaMs = now - lastTimeRef.current;
|
|
||||||
lastTimeRef.current = now;
|
|
||||||
|
|
||||||
// Update master clock
|
|
||||||
tick(deltaMs);
|
|
||||||
|
|
||||||
const currentTime = useStoryStore.getState().currentTimeMs;
|
|
||||||
const totalDuration = useStoryStore.getState().totalDurationMs;
|
const totalDuration = useStoryStore.getState().totalDurationMs;
|
||||||
|
|
||||||
// Check if we need to start playing a different item
|
// Update store with current story time
|
||||||
const activeItem = findActiveItem(currentTime, items);
|
useStoryStore.setState({ currentTimeMs: Math.min(currentStoryTime, totalDuration) });
|
||||||
|
|
||||||
if (activeItem && currentItemIdRef.current !== activeItem.generation_id) {
|
// Schedule any items that should be playing
|
||||||
// Need to switch to a different item
|
schedulePlayback(currentStoryTime, itemList);
|
||||||
const offset = currentTime - activeItem.start_time_ms;
|
|
||||||
playItem(activeItem, offset);
|
|
||||||
} else if (!activeItem && currentItemIdRef.current) {
|
|
||||||
// We're in a gap between items, pause audio
|
|
||||||
audio.pause();
|
|
||||||
currentItemIdRef.current = null;
|
|
||||||
|
|
||||||
// Check if there's a next item to wait for
|
// Check if we've reached the end
|
||||||
const nextItem = findNextItem(currentTime, items);
|
if (currentStoryTime >= totalDuration) {
|
||||||
if (!nextItem && currentTime >= totalDuration) {
|
// Check if all sources have ended
|
||||||
|
if (activeSourcesRef.current.size === 0) {
|
||||||
console.log('[StoryPlayback] Reached end');
|
console.log('[StoryPlayback] Reached end');
|
||||||
useStoryStore.getState().stop();
|
useStoryStore.getState().stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue loop
|
// Continue sync loop
|
||||||
animationFrameRef.current = requestAnimationFrame(updateClock);
|
animationFrameRef.current = requestAnimationFrame(syncPlayhead);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start with the first item
|
// Initial sync
|
||||||
const currentTime = useStoryStore.getState().currentTimeMs;
|
const currentContextTime = audioContext.currentTime;
|
||||||
const activeItem = findActiveItem(currentTime, items);
|
const currentStoryTime = contextTimeToStoryTime(currentContextTime);
|
||||||
|
schedulePlayback(currentStoryTime, itemList);
|
||||||
|
|
||||||
if (activeItem) {
|
// Start sync loop
|
||||||
const offset = currentTime - activeItem.start_time_ms;
|
animationFrameRef.current = requestAnimationFrame(syncPlayhead);
|
||||||
playItem(activeItem, offset);
|
|
||||||
} else {
|
|
||||||
// Maybe we're before all items start, find the first one
|
|
||||||
const firstItem = [...items].sort((a, b) => a.start_time_ms - b.start_time_ms)[0];
|
|
||||||
if (firstItem && currentTime < firstItem.start_time_ms) {
|
|
||||||
// Wait for the first item
|
|
||||||
console.log('[StoryPlayback] Waiting for first item at', firstItem.start_time_ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start clock
|
|
||||||
lastTimeRef.current = Date.now();
|
|
||||||
animationFrameRef.current = requestAnimationFrame(updateClock);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
audio.onended = null;
|
|
||||||
audio.onloadedmetadata = null;
|
|
||||||
audio.onerror = null;
|
|
||||||
if (animationFrameRef.current !== null) {
|
if (animationFrameRef.current !== null) {
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
animationFrameRef.current = null;
|
animationFrameRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isPlaying, playbackItems, getAudio, findActiveItem, findNextItem, tick]);
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
playbackItems,
|
||||||
|
playbackStartContextTime,
|
||||||
|
playbackStartStoryTime,
|
||||||
|
getAudioContext,
|
||||||
|
contextTimeToStoryTime,
|
||||||
|
schedulePlayback,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle play/pause/seek changes - set timing anchors and schedule playback
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !playbackItems || playbackItems.length === 0) {
|
||||||
|
console.log('[StoryPlayback] Stopping playback');
|
||||||
|
stopAllSources();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
const currentStoryTime = currentTimeMs;
|
||||||
|
|
||||||
|
// If timing anchors are not set (or were reset by seek), set them now
|
||||||
|
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
console.log('[StoryPlayback] Setting timing anchors:', {
|
||||||
|
contextTime: currentContextTime,
|
||||||
|
storyTime: currentStoryTime,
|
||||||
|
});
|
||||||
|
setPlaybackTiming(currentContextTime, currentStoryTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop all existing sources
|
||||||
|
stopAllSources();
|
||||||
|
|
||||||
|
// Schedule playback from current position
|
||||||
|
schedulePlayback(currentStoryTime, playbackItems);
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
playbackItems,
|
||||||
|
currentTimeMs,
|
||||||
|
playbackStartContextTime,
|
||||||
|
playbackStartStoryTime,
|
||||||
|
getAudioContext,
|
||||||
|
stopAllSources,
|
||||||
|
schedulePlayback,
|
||||||
|
setPlaybackTiming,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ interface StoryPlaybackState {
|
|||||||
totalDurationMs: number;
|
totalDurationMs: number;
|
||||||
playbackStoryId: string | null;
|
playbackStoryId: string | null;
|
||||||
playbackItems: StoryItemDetail[] | null;
|
playbackItems: StoryItemDetail[] | null;
|
||||||
|
// Web Audio API timing (null when not playing)
|
||||||
|
playbackStartContextTime: number | null; // AudioContext.currentTime when playback started
|
||||||
|
playbackStartStoryTime: number | null; // Story time (ms) when playback started
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
play: (storyId: string, items: StoryItemDetail[]) => void;
|
play: (storyId: string, items: StoryItemDetail[]) => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
seek: (timeMs: number) => void;
|
seek: (timeMs: number) => void;
|
||||||
tick: (deltaMs: number) => void; // Called by animation frame
|
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
||||||
@@ -32,6 +35,8 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
totalDurationMs: 0,
|
totalDurationMs: 0,
|
||||||
playbackStoryId: null,
|
playbackStoryId: null,
|
||||||
playbackItems: null,
|
playbackItems: null,
|
||||||
|
playbackStartContextTime: null,
|
||||||
|
playbackStartStoryTime: null,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
play: (storyId, items) => {
|
play: (storyId, items) => {
|
||||||
@@ -72,7 +77,10 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
pause: () => {
|
pause: () => {
|
||||||
set({ isPlaying: false });
|
set({
|
||||||
|
isPlaying: false,
|
||||||
|
// Keep timing anchors so we can resume from same position
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
stop: () => {
|
stop: () => {
|
||||||
@@ -82,32 +90,26 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
playbackStoryId: null,
|
playbackStoryId: null,
|
||||||
playbackItems: null,
|
playbackItems: null,
|
||||||
totalDurationMs: 0,
|
totalDurationMs: 0,
|
||||||
|
playbackStartContextTime: null,
|
||||||
|
playbackStartStoryTime: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
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({ currentTimeMs: clampedTime });
|
set({
|
||||||
|
currentTimeMs: clampedTime,
|
||||||
|
// Reset timing anchors - will be set by hook when playback resumes
|
||||||
|
playbackStartContextTime: null,
|
||||||
|
playbackStartStoryTime: null,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
tick: (deltaMs) => {
|
setPlaybackTiming: (contextTime, storyTime) => {
|
||||||
const state = get();
|
set({
|
||||||
if (!state.isPlaying || !state.playbackItems) {
|
playbackStartContextTime: contextTime,
|
||||||
return;
|
playbackStartStoryTime: storyTime,
|
||||||
}
|
});
|
||||||
|
|
||||||
const newTime = state.currentTimeMs + deltaMs;
|
|
||||||
const clampedTime = Math.min(newTime, state.totalDurationMs);
|
|
||||||
|
|
||||||
// Auto-stop when reaching the end
|
|
||||||
if (clampedTime >= state.totalDurationMs) {
|
|
||||||
set({
|
|
||||||
currentTimeMs: state.totalDurationMs,
|
|
||||||
isPlaying: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
set({ currentTimeMs: clampedTime });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class StoryItem(Base):
|
|||||||
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
|
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
|
||||||
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
||||||
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
||||||
|
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
@@ -246,6 +247,16 @@ def _run_migrations(engine):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
print("Migrated story_items table to use start_time_ms (removed position column)")
|
print("Migrated story_items table to use start_time_ms (removed position column)")
|
||||||
|
|
||||||
|
# Migration: Add track column if it doesn't exist
|
||||||
|
# Re-check columns after potential position migration
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||||
|
if 'track' not in columns:
|
||||||
|
print("Migrating story_items: adding track column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE story_items ADD COLUMN track INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added track column to story_items")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Get database session (generator for dependency injection)."""
|
"""Get database session (generator for dependency injection)."""
|
||||||
|
|||||||
@@ -809,6 +809,20 @@ async def reorder_story_items(
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}/items/{generation_id}/move", response_model=models.StoryItemDetail)
|
||||||
|
async def move_story_item(
|
||||||
|
story_id: str,
|
||||||
|
generation_id: str,
|
||||||
|
data: models.StoryItemMove,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Move a story item (update position and/or track)."""
|
||||||
|
item = await stories.move_story_item(story_id, generation_id, data, db)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
@app.get("/stories/{story_id}/export-audio")
|
@app.get("/stories/{story_id}/export-audio")
|
||||||
async def export_story_audio(
|
async def export_story_audio(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ class StoryItemDetail(BaseModel):
|
|||||||
story_id: str
|
story_id: str
|
||||||
generation_id: str
|
generation_id: str
|
||||||
start_time_ms: int
|
start_time_ms: int
|
||||||
|
track: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# Generation details
|
# Generation details
|
||||||
profile_id: str
|
profile_id: str
|
||||||
@@ -253,6 +254,7 @@ class StoryItemCreate(BaseModel):
|
|||||||
"""Request model for adding a generation to a story."""
|
"""Request model for adding a generation to a story."""
|
||||||
generation_id: str
|
generation_id: str
|
||||||
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
|
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
|
||||||
|
track: Optional[int] = 0 # Track number (0 = main track)
|
||||||
|
|
||||||
|
|
||||||
class StoryItemUpdateTime(BaseModel):
|
class StoryItemUpdateTime(BaseModel):
|
||||||
@@ -269,3 +271,9 @@ class StoryItemBatchUpdate(BaseModel):
|
|||||||
class StoryItemReorder(BaseModel):
|
class StoryItemReorder(BaseModel):
|
||||||
"""Request model for reordering story items."""
|
"""Request model for reordering story items."""
|
||||||
generation_ids: List[str] = Field(..., min_length=1)
|
generation_ids: List[str] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemMove(BaseModel):
|
||||||
|
"""Request model for moving a story item (position and/or track)."""
|
||||||
|
start_time_ms: int = Field(..., ge=0)
|
||||||
|
track: int = 0
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .models import (
|
|||||||
StoryItemDetail,
|
StoryItemDetail,
|
||||||
StoryItemCreate,
|
StoryItemCreate,
|
||||||
StoryItemBatchUpdate,
|
StoryItemBatchUpdate,
|
||||||
|
StoryItemMove,
|
||||||
)
|
)
|
||||||
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
from .utils.audio import load_audio, save_audio
|
from .utils.audio import load_audio, save_audio
|
||||||
@@ -127,6 +128,7 @@ async def get_story(
|
|||||||
story_id=item.story_id,
|
story_id=item.story_id,
|
||||||
generation_id=item.generation_id,
|
generation_id=item.generation_id,
|
||||||
start_time_ms=item.start_time_ms,
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
@@ -249,6 +251,7 @@ async def add_item_to_story(
|
|||||||
story_id=existing.story_id,
|
story_id=existing.story_id,
|
||||||
generation_id=existing.generation_id,
|
generation_id=existing.generation_id,
|
||||||
start_time_ms=existing.start_time_ms,
|
start_time_ms=existing.start_time_ms,
|
||||||
|
track=existing.track,
|
||||||
created_at=existing.created_at,
|
created_at=existing.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile.name if profile else "Unknown",
|
profile_name=profile.name if profile else "Unknown",
|
||||||
@@ -288,12 +291,16 @@ async def add_item_to_story(
|
|||||||
# Add 200ms gap after the last item
|
# Add 200ms gap after the last item
|
||||||
start_time_ms = max_end_time_ms + 200
|
start_time_ms = max_end_time_ms + 200
|
||||||
|
|
||||||
|
# Get track from data or default to 0
|
||||||
|
track = data.track if data.track is not None else 0
|
||||||
|
|
||||||
# Create item
|
# Create item
|
||||||
item = DBStoryItem(
|
item = DBStoryItem(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
story_id=story_id,
|
story_id=story_id,
|
||||||
generation_id=data.generation_id,
|
generation_id=data.generation_id,
|
||||||
start_time_ms=start_time_ms,
|
start_time_ms=start_time_ms,
|
||||||
|
track=track,
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -313,6 +320,72 @@ async def add_item_to_story(
|
|||||||
story_id=item.story_id,
|
story_id=item.story_id,
|
||||||
generation_id=item.generation_id,
|
generation_id=item.generation_id,
|
||||||
start_time_ms=item.start_time_ms,
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile.name if profile else "Unknown",
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def move_story_item(
|
||||||
|
story_id: str,
|
||||||
|
generation_id: str,
|
||||||
|
data: StoryItemMove,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryItemDetail]:
|
||||||
|
"""
|
||||||
|
Move a story item (update position and/or track).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
generation_id: Generation ID of the item to move
|
||||||
|
data: New position and track data
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated item detail or None if not found
|
||||||
|
"""
|
||||||
|
# Get the item
|
||||||
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=generation_id
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update position and track
|
||||||
|
item.start_time_ms = data.start_time_ms
|
||||||
|
item.track = data.track
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile.name if profile else "Unknown",
|
profile_name=profile.name if profile else "Unknown",
|
||||||
@@ -464,6 +537,7 @@ async def reorder_story_items(
|
|||||||
story_id=item.story_id,
|
story_id=item.story_id,
|
||||||
generation_id=item.generation_id,
|
generation_id=item.generation_id,
|
||||||
start_time_ms=item.start_time_ms,
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user