mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
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:
@@ -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;
|
||||||
@@ -34,13 +39,13 @@ export function StoryContent() {
|
|||||||
const exportAudio = useExportStoryAudio();
|
const exportAudio = useExportStoryAudio();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Get track editor height from store for dynamic padding
|
// Get track editor height from store for dynamic padding
|
||||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
|
||||||
// Track editor is shown when story has items
|
// Track editor is shown when story has items
|
||||||
const hasBottomBar = story && story.items.length > 0;
|
const hasBottomBar = story && story.items.length > 0;
|
||||||
|
|
||||||
// Calculate dynamic bottom padding: track editor + generate box + gap
|
// Calculate dynamic bottom padding: track editor + generate box + gap
|
||||||
const bottomPadding = hasBottomBar ? trackEditorHeight + GENERATE_BOX_HEIGHT + 24 : 0;
|
const bottomPadding = hasBottomBar ? trackEditorHeight + GENERATE_BOX_HEIGHT + 24 : 0;
|
||||||
|
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user