-
+
+ {/* Main content area */}
+
+ {/* Left Column - Story List */}
+
+
+
+
+ {/* Right Column - Story Content */}
+
+
+
+
+ {/* Floating Generate Box */}
+
- {/* Right Column - Story Content */}
-
-
-
-
- {/* Floating Generate Box */}
-
+ {/* Track Editor - at bottom when a story with items is selected */}
+ {hasTrackEditor && (
+
+
+
+ )}
);
}
diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx
new file mode 100644
index 00000000..34e4d982
--- /dev/null
+++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx
@@ -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
(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(null);
+ const tracksRef = useRef(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) => {
+ 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 (
+
+
Add audio clips to see the track editor
+
+ );
+ }
+
+ return (
+
+ {/* Resize handle at top */}
+
+
+ {/* Toolbar */}
+
+
Zoom:
+
+
+
+ {Math.round(pixelsPerSecond)}px/s
+
+
+
+ {/* Timeline container - drag handlers are intentional for drag-and-drop UX */}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
+
+ {/* Time ruler */}
+
+ {timeMarkers.map((ms) => (
+
+
+
+ {formatTime(ms)}
+
+
+ ))}
+
+
+ {/* Tracks area */}
+
+ {/* Track backgrounds */}
+ {tracks.map((trackNumber, index) => (
+
+
+ Track {trackNumber}
+
+
+ ))}
+
+ {/* Click area for seeking - z-index lower than clips */}
+
+
+ {/* Audio clips */}
+ {items.map((item) => {
+ const isDragging = draggingItem === item.generation_id;
+ const style = getClipStyle(item);
+
+ return (
+
+ );
+ })}
+
+ {/* Playhead */}
+ {isActiveStory && (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index 0a77054c..5505196f 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -20,6 +20,7 @@ import type {
StoryItemDetail,
StoryItemBatchUpdate,
StoryItemReorder,
+ StoryItemMove,
} from './types';
class ApiClient {
@@ -425,6 +426,13 @@ class ApiClient {
});
}
+ async moveStoryItem(storyId: string, generationId: string, data: StoryItemMove): Promise {
+ return this.request(`/stories/${storyId}/items/${generationId}/move`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ });
+ }
+
async exportStoryAudio(storyId: string): Promise {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url);
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index d6172652..d8321dfe 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -143,6 +143,7 @@ export interface StoryItemDetail {
story_id: string;
generation_id: string;
start_time_ms: number;
+ track: number;
created_at: string;
profile_id: string;
profile_name: string;
@@ -167,6 +168,7 @@ export interface StoryDetailResponse {
export interface StoryItemCreate {
generation_id: string;
start_time_ms?: number;
+ track?: number;
}
export interface StoryItemUpdateTime {
@@ -181,3 +183,8 @@ export interface StoryItemBatchUpdate {
export interface StoryItemReorder {
generation_ids: string[];
}
+
+export interface StoryItemMove {
+ start_time_ms: number;
+ track: number;
+}
diff --git a/app/src/lib/hooks/useStories.ts b/app/src/lib/hooks/useStories.ts
index 8e6f2212..a100f837 100644
--- a/app/src/lib/hooks/useStories.ts
+++ b/app/src/lib/hooks/useStories.ts
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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';
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() {
return useMutation({
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
diff --git a/app/src/lib/hooks/useStoryPlayback.ts b/app/src/lib/hooks/useStoryPlayback.ts
index f3e65f53..80c0cdf2 100644
--- a/app/src/lib/hooks/useStoryPlayback.ts
+++ b/app/src/lib/hooks/useStoryPlayback.ts
@@ -1,77 +1,274 @@
import { useEffect, useRef, useCallback } from 'react';
-import { useStoryStore } from '@/stores/storyStore';
import { apiClient } from '@/lib/api/client';
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.
- * Uses a single audio element for reliable playback.
+ * Hook for managing timecode-based story playback using Web Audio API.
+ * 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 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
- const audioRef = useRef(null);
- const currentItemIdRef = useRef(null);
+ // AudioContext instance (created once)
+ const audioContextRef = useRef(null);
+ // Master gain for volume control
+ const masterGainRef = useRef(null);
+ // Preloaded AudioBuffers by generation_id
+ const audioBuffersRef = useRef