Enhance StoryContent and StoryTrackEditor for improved playback and UI dynamics

- Added auto-scrolling functionality to StoryContent for the currently playing item, enhancing user experience during playback.
- Refactored StoryTrackEditor to dynamically calculate container width, ensuring proper layout for varying story lengths.
- Updated audio playback management to improve timing anchor handling and playback scheduling.
- Cleaned up import statements for better organization and readability across components.
This commit is contained in:
Jamie Pine
2026-01-28 20:15:59 -08:00
parent 232d231788
commit c4884a0443
3 changed files with 116 additions and 37 deletions
+70 -14
View File
@@ -14,14 +14,19 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { Download, Pause, Play } from 'lucide-react'; import { Download, Pause, Play } from 'lucide-react';
import { useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { useStory, useRemoveStoryItem, useExportStoryAudio, useReorderStoryItems } from '@/lib/hooks/useStories'; import { useToast } from '@/components/ui/use-toast';
import {
useStory,
useRemoveStoryItem,
useExportStoryAudio,
useReorderStoryItems,
} from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import { SortableStoryChatItem } from './StoryChatItem'; import { SortableStoryChatItem } from './StoryChatItem';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
// Height of the floating generate box plus some padding // Height of the floating generate box plus some padding
const GENERATE_BOX_HEIGHT = 160; const GENERATE_BOX_HEIGHT = 160;
@@ -66,6 +71,10 @@ export function StoryContent() {
const stop = useStoryStore((state) => state.stop); const stop = useStoryStore((state) => state.stop);
const seek = useStoryStore((state) => state.seek); const seek = useStoryStore((state) => state.seek);
// Refs for auto-scrolling to playing item
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
const lastScrolledItemRef = useRef<string | null>(null);
// Use playback hook // Use playback hook
useStoryPlayback(story?.items); useStoryPlayback(story?.items);
@@ -75,6 +84,39 @@ export function StoryContent() {
return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms); return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms);
}, [story?.items]); }, [story?.items]);
// Find the currently playing item based on timecode
const currentlyPlayingItemId = useMemo(() => {
if (!isPlaying || playbackStoryId !== story?.id || !sortedItems.length) {
return null;
}
const playingItem = sortedItems.find((item) => {
const itemStart = item.start_time_ms;
const itemEnd = item.start_time_ms + item.duration * 1000;
return currentTimeMs >= itemStart && currentTimeMs < itemEnd;
});
return playingItem?.generation_id ?? null;
}, [isPlaying, playbackStoryId, story?.id, sortedItems, currentTimeMs]);
// Auto-scroll to the currently playing item
useEffect(() => {
if (!currentlyPlayingItemId || currentlyPlayingItemId === lastScrolledItemRef.current) {
return;
}
const element = itemRefsMap.current.get(currentlyPlayingItemId);
if (element && scrollRef.current) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
lastScrolledItemRef.current = currentlyPlayingItemId;
}
}, [currentlyPlayingItemId]);
// Reset last scrolled item when playback stops
useEffect(() => {
if (!isPlaying) {
lastScrolledItemRef.current = null;
}
}, [isPlaying]);
const handleRemoveItem = (generationId: string) => { const handleRemoveItem = (generationId: string) => {
if (!story) return; if (!story) return;
@@ -235,7 +277,12 @@ export function StoryContent() {
Stop Stop
</Button> </Button>
)} )}
<Button variant="outline" size="sm" onClick={handleExportAudio} disabled={exportAudio.isPending}> <Button
variant="outline"
size="sm"
onClick={handleExportAudio}
disabled={exportAudio.isPending}
>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Export Audio Export Audio
</Button> </Button>
@@ -288,15 +335,25 @@ export function StoryContent() {
> >
<div className="space-y-3"> <div className="space-y-3">
{sortedItems.map((item, index) => ( {sortedItems.map((item, index) => (
<SortableStoryChatItem <div
key={item.id} key={item.id}
item={item} ref={(el) => {
storyId={story.id} if (el) {
index={index} itemRefsMap.current.set(item.generation_id, el);
onRemove={() => handleRemoveItem(item.generation_id)} } else {
currentTimeMs={currentTimeMs} itemRefsMap.current.delete(item.generation_id);
isPlaying={isPlaying && playbackStoryId === story.id} }
/> }}
>
<SortableStoryChatItem
item={item}
storyId={story.id}
index={index}
onRemove={() => handleRemoveItem(item.generation_id)}
currentTimeMs={currentTimeMs}
isPlaying={isPlaying && playbackStoryId === story.id}
/>
</div>
))} ))}
</div> </div>
</SortableContext> </SortableContext>
@@ -306,4 +363,3 @@ export function StoryContent() {
</div> </div>
); );
} }
@@ -26,6 +26,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const tracksRef = useRef<HTMLDivElement>(null); const tracksRef = useRef<HTMLDivElement>(null);
const resizeStartY = useRef(0); const resizeStartY = useRef(0);
@@ -50,6 +51,24 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return Array.from(trackSet).sort((a, b) => b - a); // Higher tracks on top return Array.from(trackSet).sort((a, b) => b - a); // Higher tracks on top
}, [items]); }, [items]);
// Track container width for full-width minimum
useEffect(() => {
const container = tracksRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(container);
// Set initial width
setContainerWidth(container.clientWidth);
return () => observer.disconnect();
}, []);
// Calculate total duration // Calculate total duration
const totalDurationMs = useMemo(() => { const totalDurationMs = useMemo(() => {
if (items.length === 0) return 10000; // Default 10 seconds if (items.length === 0) return 10000; // Default 10 seconds
@@ -59,8 +78,9 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
); );
}, [items]); }, [items]);
// Calculate timeline width // Calculate timeline width - at least full container width
const timelineWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Extra padding const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
const timelineWidth = Math.max(contentWidth, containerWidth);
// Generate time markers // Generate time markers
const timeMarkers = useMemo(() => { const timeMarkers = useMemo(() => {
+21 -18
View File
@@ -20,7 +20,6 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const playbackItems = useStoryStore((state) => state.playbackItems); const playbackItems = useStoryStore((state) => state.playbackItems);
const playbackStartContextTime = useStoryStore((state) => state.playbackStartContextTime); const playbackStartContextTime = useStoryStore((state) => state.playbackStartContextTime);
const playbackStartStoryTime = useStoryStore((state) => state.playbackStartStoryTime); const playbackStartStoryTime = useStoryStore((state) => state.playbackStartStoryTime);
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
const setPlaybackTiming = useStoryStore((state) => state.setPlaybackTiming); const setPlaybackTiming = useStoryStore((state) => state.setPlaybackTiming);
// AudioContext instance (created once) // AudioContext instance (created once)
@@ -44,9 +43,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
); );
// Create master gain node for volume control // Create master gain node for volume control
// Set to 0.5 to prevent distortion from overlapping audio
masterGainRef.current = audioContextRef.current.createGain(); masterGainRef.current = audioContextRef.current.createGain();
masterGainRef.current.gain.value = 0.05; masterGainRef.current.gain.value = 1;
masterGainRef.current.connect(audioContextRef.current.destination); masterGainRef.current.connect(audioContextRef.current.destination);
} }
// Resume context if suspended (browser autoplay policy) // Resume context if suspended (browser autoplay policy)
@@ -332,36 +330,41 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
schedulePlayback, schedulePlayback,
]); ]);
// Handle play/pause/seek changes - set timing anchors and schedule playback // Handle play/pause changes - stop sources when paused
useEffect(() => { useEffect(() => {
if (!isPlaying || !playbackItems || playbackItems.length === 0) { if (!isPlaying) {
console.log('[StoryPlayback] Stopping playback'); console.log('[StoryPlayback] Stopping playback');
stopAllSources(); stopAllSources();
}
}, [isPlaying, stopAllSources]);
// Handle seek - reset timing anchors when they become null (triggered by seek)
useEffect(() => {
if (!isPlaying || !playbackItems || playbackItems.length === 0) {
return;
}
// Only run when timing anchors are null (after a seek)
if (playbackStartContextTime !== null && playbackStartStoryTime !== null) {
return; return;
} }
const audioContext = getAudioContext(); const audioContext = getAudioContext();
const currentContextTime = audioContext.currentTime; const currentContextTime = audioContext.currentTime;
const currentStoryTime = currentTimeMs; const currentStoryTime = useStoryStore.getState().currentTimeMs;
// If timing anchors are not set (or were reset by seek), set them now console.log('[StoryPlayback] Setting timing anchors after seek:', {
if (playbackStartContextTime === null || playbackStartStoryTime === null) { contextTime: currentContextTime,
console.log('[StoryPlayback] Setting timing anchors:', { storyTime: currentStoryTime,
contextTime: currentContextTime, });
storyTime: currentStoryTime, setPlaybackTiming(currentContextTime, currentStoryTime);
});
setPlaybackTiming(currentContextTime, currentStoryTime);
}
// Stop all existing sources // Stop all existing sources and reschedule from new position
stopAllSources(); stopAllSources();
// Schedule playback from current position
schedulePlayback(currentStoryTime, playbackItems); schedulePlayback(currentStoryTime, playbackItems);
}, [ }, [
isPlaying, isPlaying,
playbackItems, playbackItems,
currentTimeMs,
playbackStartContextTime, playbackStartContextTime,
playbackStartStoryTime, playbackStartStoryTime,
getAudioContext, getAudioContext,