From c4884a0443c18e830fecea2cc5b54dc02fb36660 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Wed, 28 Jan 2026 20:15:59 -0800 Subject: [PATCH] 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. --- .../components/StoriesTab/StoryContent.tsx | 90 +++++++++++++++---- .../StoriesTab/StoryTrackEditor.tsx | 24 ++++- app/src/lib/hooks/useStoryPlayback.ts | 39 ++++---- 3 files changed, 116 insertions(+), 37 deletions(-) diff --git a/app/src/components/StoriesTab/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx index bd38a987..198aeb66 100644 --- a/app/src/components/StoriesTab/StoryContent.tsx +++ b/app/src/components/StoriesTab/StoryContent.tsx @@ -14,14 +14,19 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; 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 { useToast } from '@/components/ui/use-toast'; 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 { SortableStoryChatItem } from './StoryChatItem'; -import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback'; // Height of the floating generate box plus some padding const GENERATE_BOX_HEIGHT = 160; @@ -34,13 +39,13 @@ export function StoryContent() { const exportAudio = useExportStoryAudio(); const { toast } = useToast(); const scrollRef = useRef(null); - + // Get track editor height from store for dynamic padding const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); - + // Track editor is shown when story has items const hasBottomBar = story && story.items.length > 0; - + // Calculate dynamic bottom padding: track editor + generate box + gap const bottomPadding = hasBottomBar ? trackEditorHeight + GENERATE_BOX_HEIGHT + 24 : 0; @@ -66,6 +71,10 @@ export function StoryContent() { const stop = useStoryStore((state) => state.stop); const seek = useStoryStore((state) => state.seek); + // Refs for auto-scrolling to playing item + const itemRefsMap = useRef>(new Map()); + const lastScrolledItemRef = useRef(null); + // Use playback hook useStoryPlayback(story?.items); @@ -75,6 +84,39 @@ export function StoryContent() { return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms); }, [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) => { if (!story) return; @@ -235,7 +277,12 @@ export function StoryContent() { Stop )} - @@ -288,15 +335,25 @@ export function StoryContent() { >
{sortedItems.map((item, index) => ( - handleRemoveItem(item.generation_id)} - currentTimeMs={currentTimeMs} - isPlaying={isPlaying && playbackStoryId === story.id} - /> + ref={(el) => { + if (el) { + itemRefsMap.current.set(item.generation_id, el); + } else { + itemRefsMap.current.delete(item.generation_id); + } + }} + > + handleRemoveItem(item.generation_id)} + currentTimeMs={currentTimeMs} + isPlaying={isPlaying && playbackStoryId === story.id} + /> +
))} @@ -306,4 +363,3 @@ export function StoryContent() { ); } - diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index d46d2189..c1aad487 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -26,6 +26,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const [isResizing, setIsResizing] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); const containerRef = useRef(null); const tracksRef = useRef(null); 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 }, [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 const totalDurationMs = useMemo(() => { if (items.length === 0) return 10000; // Default 10 seconds @@ -59,8 +78,9 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { ); }, [items]); - // Calculate timeline width - const timelineWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Extra padding + // Calculate timeline width - at least full container width + const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding + const timelineWidth = Math.max(contentWidth, containerWidth); // Generate time markers const timeMarkers = useMemo(() => { diff --git a/app/src/lib/hooks/useStoryPlayback.ts b/app/src/lib/hooks/useStoryPlayback.ts index 86da1de3..83b32088 100644 --- a/app/src/lib/hooks/useStoryPlayback.ts +++ b/app/src/lib/hooks/useStoryPlayback.ts @@ -20,7 +20,6 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { const playbackItems = useStoryStore((state) => state.playbackItems); const playbackStartContextTime = useStoryStore((state) => state.playbackStartContextTime); const playbackStartStoryTime = useStoryStore((state) => state.playbackStartStoryTime); - const currentTimeMs = useStoryStore((state) => state.currentTimeMs); const setPlaybackTiming = useStoryStore((state) => state.setPlaybackTiming); // AudioContext instance (created once) @@ -44,9 +43,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { ); // 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.gain.value = 1; masterGainRef.current.connect(audioContextRef.current.destination); } // Resume context if suspended (browser autoplay policy) @@ -332,36 +330,41 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { schedulePlayback, ]); - // Handle play/pause/seek changes - set timing anchors and schedule playback + // Handle play/pause changes - stop sources when paused useEffect(() => { - if (!isPlaying || !playbackItems || playbackItems.length === 0) { + if (!isPlaying) { console.log('[StoryPlayback] Stopping playback'); 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; } const audioContext = getAudioContext(); 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 - if (playbackStartContextTime === null || playbackStartStoryTime === null) { - console.log('[StoryPlayback] Setting timing anchors:', { - contextTime: currentContextTime, - storyTime: currentStoryTime, - }); - setPlaybackTiming(currentContextTime, currentStoryTime); - } + console.log('[StoryPlayback] Setting timing anchors after seek:', { + contextTime: currentContextTime, + storyTime: currentStoryTime, + }); + setPlaybackTiming(currentContextTime, currentStoryTime); - // Stop all existing sources + // Stop all existing sources and reschedule from new position stopAllSources(); - - // Schedule playback from current position schedulePlayback(currentStoryTime, playbackItems); }, [ isPlaying, playbackItems, - currentTimeMs, playbackStartContextTime, playbackStartStoryTime, getAudioContext,