Implement story management features and update dependencies

- Introduced story management functionality, including creating, listing, and managing story items.
- Added new components for story display and interaction, including StoriesTab, StoryList, and StoryContent.
- Integrated drag-and-drop functionality for reordering story items using @dnd-kit.
- Updated dependencies for @dnd-kit packages to enhance drag-and-drop capabilities.
- Bumped version for @voicebox/app, @voicebox/landing, @voicebox/tauri, and @voicebox/web to 0.1.5.
- Enhanced audio playback features to support story mode with auto-play functionality.
- Improved error handling and user feedback through toast notifications in story-related actions.
This commit is contained in:
Jamie Pine
2026-01-28 19:10:58 -08:00
parent 9d5d6cb56a
commit 3204e193fa
22 changed files with 2396 additions and 29 deletions
@@ -402,6 +402,11 @@ export function AudioPlayer() {
wavesurfer.play();
} else {
setIsPlaying(false);
// Trigger finish callback if set
const onFinish = usePlayerStore.getState().onFinish;
if (onFinish) {
onFinish();
}
}
});
@@ -653,6 +658,29 @@ export function AudioPlayer() {
clearRestartFlag();
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
// Handle shouldAutoPlay flag - for story mode auto-advance
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
return;
}
// Auto-play the newly loaded audio
debug.log('Auto-playing next track in story mode');
wavesurfer.seekTo(0);
wavesurfer.play().catch((error) => {
debug.error('Failed to auto-play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
// Clear the auto-play flag
clearAutoPlayFlag();
}, [shouldAutoPlay, duration, setIsPlaying, clearAutoPlayFlag]);
// Handle loop - WaveSurfer handles this via the 'finish' event
const handlePlayPause = async () => {
@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, Sparkles } from 'lucide-react';
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useMatchRoute } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
@@ -13,22 +14,55 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useStory, useAddStoryItem } from '@/lib/hooks/useStories';
import { useUIStore } from '@/stores/uiStore';
import { useStoryStore } from '@/stores/storyStore';
import { useToast } from '@/components/ui/use-toast';
import { cn } from '@/lib/utils/cn';
interface FloatingGenerateBoxProps {
isPlayerOpen: boolean;
showVoiceSelector?: boolean;
}
export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) {
export function FloatingGenerateBox({ isPlayerOpen, showVoiceSelector = false }: FloatingGenerateBoxProps) {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { data: profiles } = useProfiles();
const [isExpanded, setIsExpanded] = useState(false);
const [isInstructMode, setIsInstructMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const matchRoute = useMatchRoute();
const isStoriesRoute = matchRoute({ to: '/stories' });
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const { data: currentStory } = useStory(selectedStoryId);
const addStoryItem = useAddStoryItem();
const { toast } = useToast();
const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: () => {
onSuccess: async (generationId) => {
setIsExpanded(false);
// If on stories route and a story is selected, add generation to story
if (isStoriesRoute && selectedStoryId && generationId) {
try {
await addStoryItem.mutateAsync({
storyId: selectedStoryId,
data: { generation_id: generationId },
});
toast({
title: 'Added to story',
description: `Generation added to "${currentStory?.name || 'story'}"`,
});
} catch (error) {
toast({
title: 'Failed to add to story',
description: error instanceof Error ? error.message : 'Could not add generation to story',
variant: 'destructive',
});
}
}
},
});
@@ -69,7 +103,12 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
return (
<motion.div
ref={containerRef}
className="fixed left-[calc(5rem+2rem)] right-auto w-[calc((100%-5rem-4rem)/2-1rem)]"
className={cn(
'fixed right-auto',
isStoriesRoute
? 'left-[calc(5rem+2rem+((100%-5rem-4rem)/2)+1.5rem)] w-[calc((100%-5rem-4rem)/2-1rem)]'
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
)}
style={{
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
}}
@@ -85,17 +124,26 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
className="flex-1"
transition={{ duration: 0.3, ease: 'easeOut' }}
>
{isInstructMode && (
<span className="text-xs text-accent font-medium mb-1 block">
Delivery instructions:
</span>
)}
<FormField
control={form.control}
name="text"
name={isInstructMode ? 'instruct' : 'text'}
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
placeholder={
selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
isInstructMode
? 'Add delivery instructions...'
: isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"...`
: selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
}
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 overflow-hidden transition-all"
style={{
@@ -114,18 +162,43 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
/>
</motion.div>
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200"
size="icon"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<div className="relative shrink-0">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setIsInstructMode(!isInstructMode)}
className={`h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200 ${
isInstructMode ? 'text-accent' : ''
}`}
>
<MessageSquare className="h-4 w-4" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<AnimatePresence>
@@ -137,6 +210,24 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
className=" mt-3"
>
<div className="flex items-center gap-2">
{showVoiceSelector && (
<Select
value={selectedProfileId || ''}
onValueChange={(value) => setSelectedProfileId(value || null)}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all flex-1">
<SelectValue placeholder="Select a voice..." />
</SelectTrigger>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id} className="text-xs">
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FormField
control={form.control}
name="language"
+2 -1
View File
@@ -1,5 +1,5 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
@@ -11,6 +11,7 @@ interface SidebarProps {
const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
@@ -0,0 +1,25 @@
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
import { StoryContent } from './StoryContent';
import { StoryList } from './StoryList';
import { usePlayerStore } from '@/stores/playerStore';
export function StoriesTab() {
const audioUrl = usePlayerStore((state) => state.audioUrl);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
{/* 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} showVoiceSelector />
</div>
);
}
@@ -0,0 +1,160 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import type { StoryItemDetail } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
import { useStoryStore } from '@/stores/storyStore';
interface StoryChatItemProps {
item: StoryItemDetail;
storyId: string;
index: number;
onRemove: () => void;
currentTimeMs: number;
isPlaying: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
isDragging?: boolean;
}
export function StoryChatItem({
item,
onRemove,
currentTimeMs,
isPlaying,
dragHandleProps,
isDragging,
}: StoryChatItemProps) {
const seek = useStoryStore((state) => state.seek);
// Check if this item is currently playing based on timecode
const itemStartMs = item.start_time_ms;
const itemEndMs = item.start_time_ms + item.duration * 1000;
const isCurrentlyPlaying = isPlaying && currentTimeMs >= itemStartMs && currentTimeMs < itemEndMs;
const handlePlay = () => {
// Seek to the start of this item
seek(itemStartMs);
};
const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const milliseconds = Math.floor((ms % 1000) / 100);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
};
return (
<div
className={cn(
'flex items-start gap-3 p-4 rounded-lg border transition-colors',
isCurrentlyPlaying && 'bg-muted/70 border-primary',
!isCurrentlyPlaying && 'hover:bg-muted/50',
isDragging && 'opacity-50 shadow-lg',
)}
>
{/* Drag Handle */}
{dragHandleProps && (
<button
type="button"
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
{...dragHandleProps}
>
<GripVertical className="h-5 w-5" />
</button>
)}
{/* Voice Icon */}
<div className="shrink-0">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<Mic className="h-5 w-5 text-muted-foreground" />
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm">{item.profile_name}</span>
<span className="text-xs text-muted-foreground">{item.language}</span>
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
{formatTime(itemStartMs)}
</span>
</div>
<button
type="button"
className="w-full text-left cursor-pointer"
onClick={(e) => {
// Don't trigger play if clicking on textarea or if text is selected
const target = e.target as HTMLElement;
if (target.closest('textarea') || window.getSelection()?.toString()) {
return;
}
handlePlay();
}}
>
<Textarea
value={item.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text pointer-events-none bg-card"
readOnly
/>
</button>
</div>
{/* Actions */}
<div className="shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handlePlay}>
<Play className="mr-2 h-4 w-4" />
Play from here
</DropdownMenuItem>
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Remove from Story
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
// Sortable wrapper component
export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.item.generation_id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<StoryChatItem
{...props}
dragHandleProps={listeners}
isDragging={isDragging}
/>
</div>
);
}
@@ -0,0 +1,304 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Download, Pause, Play } from 'lucide-react';
import { useMemo, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { Slider } from '@/components/ui/slider';
import { useStory, useRemoveStoryItem, useExportStoryAudio, useReorderStoryItems } from '@/lib/hooks/useStories';
import { useStoryStore } from '@/stores/storyStore';
import { usePlayerStore } from '@/stores/playerStore';
import { SortableStoryChatItem } from './StoryChatItem';
import { cn } from '@/lib/utils/cn';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
export function StoryContent() {
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const { data: story, isLoading } = useStory(selectedStoryId);
const removeItem = useRemoveStoryItem();
const reorderItems = useReorderStoryItems();
const exportAudio = useExportStoryAudio();
const { toast } = useToast();
const scrollRef = useRef<HTMLDivElement>(null);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
// Drag and drop sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Playback state
const isPlaying = useStoryStore((state) => state.isPlaying);
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
const totalDurationMs = useStoryStore((state) => state.totalDurationMs);
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
const play = useStoryStore((state) => state.play);
const pause = useStoryStore((state) => state.pause);
const stop = useStoryStore((state) => state.stop);
const seek = useStoryStore((state) => state.seek);
// Use playback hook
useStoryPlayback(story?.items);
// Sort items by start_time_ms
const sortedItems = useMemo(() => {
if (!story?.items) return [];
return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms);
}, [story?.items]);
const handleRemoveItem = (generationId: string) => {
if (!story) return;
removeItem.mutate(
{
storyId: story.id,
generationId,
},
{
onError: (error) => {
toast({
title: 'Failed to remove item',
description: error.message,
variant: 'destructive',
});
},
},
);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!story || !over || active.id === over.id) return;
const oldIndex = sortedItems.findIndex((item) => item.generation_id === active.id);
const newIndex = sortedItems.findIndex((item) => item.generation_id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// Calculate the new order
const newOrder = arrayMove(sortedItems, oldIndex, newIndex);
const generationIds = newOrder.map((item) => item.generation_id);
// Send reorder request to backend
reorderItems.mutate(
{
storyId: story.id,
data: { generation_ids: generationIds },
},
{
onError: (error) => {
toast({
title: 'Failed to reorder items',
description: error.message,
variant: 'destructive',
});
},
},
);
};
const handlePlayPause = () => {
if (!story || story.items.length === 0) return;
if (isPlaying && playbackStoryId === story.id) {
pause();
} else {
play(story.id, sortedItems);
}
};
const handleStop = () => {
stop();
};
const handleSeek = (value: number[]) => {
seek(value[0]);
};
const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const milliseconds = Math.floor((ms % 1000) / 100);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
};
const handleExportAudio = () => {
if (!story) return;
exportAudio.mutate(
{
storyId: story.id,
storyName: story.name,
},
{
onError: (error) => {
toast({
title: 'Failed to export audio',
description: error.message,
variant: 'destructive',
});
},
},
);
};
if (!selectedStoryId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<p className="text-lg font-medium mb-2">Select a story</p>
<p className="text-sm">Choose a story from the list to view its content</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading story...</div>
</div>
);
}
if (!story) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<p className="text-lg font-medium mb-2">Story not found</p>
<p className="text-sm">The selected story could not be loaded</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex flex-col gap-4 mb-4 px-1">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">{story.name}</h2>
{story.description && (
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
)}
</div>
<div className="flex gap-2">
{story.items.length > 0 && (
<>
<Button variant="outline" size="sm" onClick={handlePlayPause}>
{isPlaying && playbackStoryId === story.id ? (
<>
<Pause className="mr-2 h-4 w-4" />
Pause
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Play
</>
)}
</Button>
{isPlaying && playbackStoryId === story.id && (
<Button variant="outline" size="sm" onClick={handleStop}>
Stop
</Button>
)}
<Button variant="outline" size="sm" onClick={handleExportAudio} disabled={exportAudio.isPending}>
<Download className="mr-2 h-4 w-4" />
Export Audio
</Button>
</>
)}
</div>
</div>
{/* Playback Controls */}
{story.items.length > 0 && (
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground tabular-nums min-w-16">
{formatTime(currentTimeMs)}
</span>
<Slider
value={[currentTimeMs]}
max={totalDurationMs || 1}
step={10}
onValueChange={handleSeek}
className="flex-1"
disabled={!isPlaying && playbackStoryId !== story.id}
/>
<span className="text-xs text-muted-foreground tabular-nums min-w-16">
{formatTime(totalDurationMs)}
</span>
</div>
)}
</div>
{/* Content */}
<div
ref={scrollRef}
className={cn(
'flex-1 min-h-0 overflow-y-auto space-y-3',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
{sortedItems.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
<p className="text-sm">No items in this story</p>
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedItems.map((item) => item.generation_id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{sortedItems.map((item, index) => (
<SortableStoryChatItem
key={item.id}
item={item}
storyId={story.id}
index={index}
onRemove={() => handleRemoveItem(item.generation_id)}
currentTimeMs={currentTimeMs}
isPlaying={isPlaying && playbackStoryId === story.id}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { Plus, BookOpen } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
import { useStories, useCreateStory } from '@/lib/hooks/useStories';
import { useStoryStore } from '@/stores/storyStore';
import { cn } from '@/lib/utils/cn';
import { formatDate } from '@/lib/utils/format';
export function StoryList() {
const { data: stories, isLoading } = useStories();
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
const createStory = useCreateStory();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newStoryName, setNewStoryName] = useState('');
const [newStoryDescription, setNewStoryDescription] = useState('');
const { toast } = useToast();
const handleCreateStory = () => {
if (!newStoryName.trim()) {
toast({
title: 'Name required',
description: 'Please enter a story name',
variant: 'destructive',
});
return;
}
createStory.mutate(
{
name: newStoryName.trim(),
description: newStoryDescription.trim() || undefined,
},
{
onSuccess: (story) => {
setSelectedStoryId(story.id);
setCreateDialogOpen(false);
setNewStoryName('');
setNewStoryDescription('');
toast({
title: 'Story created',
description: `"${story.name}" has been created`,
});
},
onError: (error) => {
toast({
title: 'Failed to create story',
description: error.message,
variant: 'destructive',
});
},
},
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading stories...</div>
</div>
);
}
const storyList = stories || [];
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4 px-1">
<h2 className="text-2xl font-bold">Stories</h2>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
New Story
</Button>
</div>
{/* Story List */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{storyList.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">No stories yet</p>
<p className="text-xs mt-2">Create your first story to get started</p>
</div>
) : (
storyList.map((story) => (
<div
key={story.id}
className={cn(
'p-4 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors',
selectedStoryId === story.id && 'bg-muted border-primary',
)}
onClick={() => setSelectedStoryId(story.id)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{story.name}</h3>
{story.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{story.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span>{story.item_count} {story.item_count === 1 ? 'item' : 'items'}</span>
<span>•</span>
<span>{formatDate(story.updated_at)}</span>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* Create Story Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Story</DialogTitle>
<DialogDescription>
Create a new story to organize your voice generations into conversations.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="story-name">Name</Label>
<Input
id="story-name"
placeholder="My Story"
value={newStoryName}
onChange={(e) => setNewStoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateStory();
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="story-description">Description (optional)</Label>
<Textarea
id="story-description"
placeholder="A conversation between..."
value={newStoryDescription}
onChange={(e) => setNewStoryDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
{createStory.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+77
View File
@@ -13,6 +13,13 @@ import type {
ModelStatusListResponse,
ModelDownloadRequest,
ActiveTasksResponse,
StoryCreate,
StoryResponse,
StoryDetailResponse,
StoryItemCreate,
StoryItemDetail,
StoryItemBatchUpdate,
StoryItemReorder,
} from './types';
class ApiClient {
@@ -361,6 +368,76 @@ class ApiClient {
body: JSON.stringify({ channel_ids: channelIds }),
});
}
// Stories
async listStories(): Promise<StoryResponse[]> {
return this.request<StoryResponse[]>('/stories');
}
async createStory(data: StoryCreate): Promise<StoryResponse> {
return this.request<StoryResponse>('/stories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async getStory(storyId: string): Promise<StoryDetailResponse> {
return this.request<StoryDetailResponse>(`/stories/${storyId}`);
}
async updateStory(storyId: string, data: StoryCreate): Promise<StoryResponse> {
return this.request<StoryResponse>(`/stories/${storyId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteStory(storyId: string): Promise<void> {
await this.request<void>(`/stories/${storyId}`, {
method: 'DELETE',
});
}
async addStoryItem(storyId: string, data: StoryItemCreate): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async removeStoryItem(storyId: string, generationId: string): Promise<void> {
await this.request<void>(`/stories/${storyId}/items/${generationId}`, {
method: 'DELETE',
});
}
async updateStoryItemTimes(storyId: string, data: StoryItemBatchUpdate): Promise<void> {
await this.request<void>(`/stories/${storyId}/items/times`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async reorderStoryItems(storyId: string, data: StoryItemReorder): Promise<StoryItemDetail[]> {
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/reorder`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async exportStoryAudio(storyId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
}
export const apiClient = new ApiClient();
+58
View File
@@ -123,3 +123,61 @@ export interface ActiveTasksResponse {
downloads: ActiveDownloadTask[];
generations: ActiveGenerationTask[];
}
export interface StoryCreate {
name: string;
description?: string;
}
export interface StoryResponse {
id: string;
name: string;
description?: string;
created_at: string;
updated_at: string;
item_count: number;
}
export interface StoryItemDetail {
id: string;
story_id: string;
generation_id: string;
start_time_ms: number;
created_at: string;
profile_id: string;
profile_name: string;
text: string;
language: string;
audio_path: string;
duration: number;
seed?: number;
instruct?: string;
generation_created_at: string;
}
export interface StoryDetailResponse {
id: string;
name: string;
description?: string;
created_at: string;
updated_at: string;
items: StoryItemDetail[];
}
export interface StoryItemCreate {
generation_id: string;
start_time_ms?: number;
}
export interface StoryItemUpdateTime {
generation_id: string;
start_time_ms: number;
}
export interface StoryItemBatchUpdate {
updates: StoryItemUpdateTime[];
}
export interface StoryItemReorder {
generation_ids: string[];
}
+2 -2
View File
@@ -21,7 +21,7 @@ const generationSchema = z.object({
export type GenerationFormValues = z.infer<typeof generationSchema>;
interface UseGenerationFormOptions {
onSuccess?: () => void;
onSuccess?: (generationId: string) => void;
defaultValues?: Partial<GenerationFormValues>;
}
@@ -100,7 +100,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset();
options.onSuccess?.();
options.onSuccess?.(result.id);
} catch (error) {
toast({
title: 'Generation failed',
+164
View File
@@ -0,0 +1,164 @@
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 { isTauri } from '@/lib/tauri';
export function useStories() {
return useQuery({
queryKey: ['stories'],
queryFn: () => apiClient.listStories(),
});
}
export function useStory(storyId: string | null) {
return useQuery({
queryKey: ['stories', storyId],
queryFn: () => apiClient.getStory(storyId!),
enabled: !!storyId,
});
}
export function useCreateStory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: StoryCreate) => apiClient.createStory(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
},
});
}
export function useUpdateStory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, data }: { storyId: string; data: StoryCreate }) =>
apiClient.updateStory(storyId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useDeleteStory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (storyId: string) => apiClient.deleteStory(storyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
},
});
}
export function useAddStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemCreate }) =>
apiClient.addStoryItem(storyId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useRemoveStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, generationId }: { storyId: string; generationId: string }) =>
apiClient.removeStoryItem(storyId, generationId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useUpdateStoryItemTimes() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemBatchUpdate }) =>
apiClient.updateStoryItemTimes(storyId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useReorderStoryItems() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemReorder }) =>
apiClient.reorderStoryItems(storyId, 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 }) => {
const blob = await apiClient.exportStoryAudio(storyId);
// Create safe filename
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
const filename = `${safeName || 'story'}.wav`;
if (isTauri()) {
// Use Tauri's native save dialog
try {
const { save } = await import('@tauri-apps/plugin-dialog');
const filePath = await save({
defaultPath: filename,
filters: [
{
name: 'Audio File',
extensions: ['wav'],
},
],
});
if (filePath) {
// Write file using Tauri's filesystem API
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
const arrayBuffer = await blob.arrayBuffer();
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
}
} catch (error) {
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
// Fall back to browser download if Tauri dialog fails
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
} else {
// Browser: trigger download
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
return blob;
},
});
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useRef, useCallback } from 'react';
import { useStoryStore } from '@/stores/storyStore';
import { apiClient } from '@/lib/api/client';
import type { StoryItemDetail } from '@/lib/api/types';
/**
* Hook for managing timecode-based story playback.
* Uses a single audio element for reliable playback.
*/
export function useStoryPlayback(_items: StoryItemDetail[] | undefined) {
const isPlaying = useStoryStore((state) => state.isPlaying);
const playbackItems = useStoryStore((state) => state.playbackItems);
const tick = useStoryStore((state) => state.tick);
// Single audio element for playback
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentItemIdRef = useRef<string | null>(null);
const animationFrameRef = useRef<number | null>(null);
const lastTimeRef = useRef<number>(Date.now());
// Get or create audio element
const getAudio = useCallback(() => {
if (!audioRef.current) {
audioRef.current = new Audio();
audioRef.current.preload = 'auto';
}
return audioRef.current;
}, []);
// Find the item that should be playing at a given time
const findActiveItem = useCallback((timeMs: number, items: StoryItemDetail[]): StoryItemDetail | null => {
for (const item of items) {
const itemStart = item.start_time_ms;
const itemEnd = item.start_time_ms + item.duration * 1000;
if (timeMs >= itemStart && timeMs < itemEnd) {
return item;
}
}
return null;
}, []);
// Find the next item after a given time
const findNextItem = useCallback((timeMs: number, items: StoryItemDetail[]): StoryItemDetail | null => {
const sorted = [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
for (const item of sorted) {
if (item.start_time_ms > timeMs) {
return item;
}
}
return null;
}, []);
// Cleanup
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
// Main playback effect
useEffect(() => {
const audio = getAudio();
if (!isPlaying || !playbackItems || playbackItems.length === 0) {
console.log('[StoryPlayback] Stopping playback');
audio.pause();
currentItemIdRef.current = null;
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
return;
}
const items = playbackItems; // Capture for closure
console.log('[StoryPlayback] Starting playback');
const playItem = (item: StoryItemDetail, offsetMs: number = 0) => {
console.log('[StoryPlayback] Playing item:', item.generation_id, 'offset:', offsetMs);
currentItemIdRef.current = item.generation_id;
const audioUrl = apiClient.getAudioUrl(item.generation_id);
audio.src = audioUrl;
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;
// Check if we need to start playing a different item
const activeItem = findActiveItem(currentTime, items);
if (activeItem && currentItemIdRef.current !== activeItem.generation_id) {
// Need to switch to a different item
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
const nextItem = findNextItem(currentTime, items);
if (!nextItem && currentTime >= totalDuration) {
console.log('[StoryPlayback] Reached end');
useStoryStore.getState().stop();
return;
}
}
// Continue loop
animationFrameRef.current = requestAnimationFrame(updateClock);
};
// Start with the first item
const currentTime = useStoryStore.getState().currentTimeMs;
const activeItem = findActiveItem(currentTime, items);
if (activeItem) {
const offset = currentTime - activeItem.start_time_ms;
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 () => {
audio.onended = null;
audio.onloadedmetadata = null;
audio.onerror = null;
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}, [isPlaying, playbackItems, getAudio, findActiveItem, findNextItem, tick]);
}
+9
View File
@@ -5,6 +5,7 @@ import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
import { ServerTab } from '@/components/ServerTab/ServerTab';
import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
@@ -77,6 +78,13 @@ const indexRoute = createRoute({
component: MainEditor,
});
// Stories route
const storiesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/stories',
component: StoriesTab,
});
// Voices route
const voicesRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -108,6 +116,7 @@ const serverRoute = createRoute({
// Route tree
const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
voicesRoute,
audioRoute,
modelsRoute,
+23
View File
@@ -11,8 +11,11 @@ interface PlayerState {
volume: number;
isLooping: boolean;
shouldRestart: boolean;
shouldAutoPlay: boolean;
onFinish: (() => void) | null;
setAudio: (url: string, id: string, profileId: string | null, title?: string) => void;
setAudioWithAutoPlay: (url: string, id: string, profileId: string | null, title?: string) => void;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
@@ -20,6 +23,8 @@ interface PlayerState {
toggleLoop: () => void;
restartCurrentAudio: () => void;
clearRestartFlag: () => void;
clearAutoPlayFlag: () => void;
setOnFinish: (callback: (() => void) | null) => void;
reset: () => void;
}
@@ -34,6 +39,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
volume: 1,
isLooping: false,
shouldRestart: false,
shouldAutoPlay: false,
onFinish: null,
setAudio: (url, id, profileId, title) =>
set({
@@ -44,6 +51,18 @@ export const usePlayerStore = create<PlayerState>((set) => ({
currentTime: 0,
isPlaying: false,
shouldRestart: false,
shouldAutoPlay: false,
}),
setAudioWithAutoPlay: (url, id, profileId, title) =>
set({
audioUrl: url,
audioId: id,
profileId: profileId || null,
title: title || null,
currentTime: 0,
isPlaying: false,
shouldRestart: false,
shouldAutoPlay: true,
}),
setIsPlaying: (playing) => set({ isPlaying: playing }),
setCurrentTime: (time) => set({ currentTime: time }),
@@ -52,6 +71,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
restartCurrentAudio: () => set({ shouldRestart: true }),
clearRestartFlag: () => set({ shouldRestart: false }),
clearAutoPlayFlag: () => set({ shouldAutoPlay: false }),
setOnFinish: (callback) => set({ onFinish: callback }),
reset: () =>
set({
audioUrl: null,
@@ -63,5 +84,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
duration: 0,
isLooping: false,
shouldRestart: false,
shouldAutoPlay: false,
onFinish: null,
}),
}));
+113
View File
@@ -0,0 +1,113 @@
import { create } from 'zustand';
import type { StoryItemDetail } from '@/lib/api/types';
interface StoryPlaybackState {
// Selection
selectedStoryId: string | null;
setSelectedStoryId: (id: string | null) => void;
// Playback state
isPlaying: boolean;
currentTimeMs: number;
totalDurationMs: number;
playbackStoryId: string | null;
playbackItems: StoryItemDetail[] | null;
// Actions
play: (storyId: string, items: StoryItemDetail[]) => void;
pause: () => void;
stop: () => void;
seek: (timeMs: number) => void;
tick: (deltaMs: number) => void; // Called by animation frame
}
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
// Selection
selectedStoryId: null,
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
// Playback state
isPlaying: false,
currentTimeMs: 0,
totalDurationMs: 0,
playbackStoryId: null,
playbackItems: null,
// Actions
play: (storyId, items) => {
// Calculate total duration from items
const maxEndTimeMs = Math.max(
...items.map((item) => item.start_time_ms + item.duration * 1000),
0
);
// Find the minimum start time (first item)
const minStartTimeMs = Math.min(
...items.map((item) => item.start_time_ms),
0
);
// If resuming the same story, keep position; otherwise start at first item
const currentState = get();
const shouldResume = currentState.playbackStoryId === storyId && currentState.currentTimeMs > 0;
const startTimeMs = shouldResume ? currentState.currentTimeMs : minStartTimeMs;
console.log('[StoryStore] Play called:', {
storyId,
itemCount: items.length,
items: items.map(i => ({ id: i.generation_id, start: i.start_time_ms, duration: i.duration })),
maxEndTimeMs,
minStartTimeMs,
startTimeMs,
shouldResume,
});
set({
isPlaying: true,
playbackStoryId: storyId,
playbackItems: items,
totalDurationMs: maxEndTimeMs,
currentTimeMs: startTimeMs,
});
},
pause: () => {
set({ isPlaying: false });
},
stop: () => {
set({
isPlaying: false,
currentTimeMs: 0,
playbackStoryId: null,
playbackItems: null,
totalDurationMs: 0,
});
},
seek: (timeMs) => {
const state = get();
const clampedTime = Math.max(0, Math.min(timeMs, state.totalDurationMs));
set({ currentTimeMs: clampedTime });
},
tick: (deltaMs) => {
const state = get();
if (!state.isPlaying || !state.playbackItems) {
return;
}
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 });
}
},
}));