diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8593dddf..4d5b8318 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.5 +current_version = 0.1.6 commit = True tag = True tag_name = v{new_version} diff --git a/app/package.json b/app/package.json index 1c8f9790..f61cf7e6 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@voicebox/app", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "scripts": { @@ -13,6 +13,9 @@ "check": "biome check --write src" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", diff --git a/app/src/components/AppFrame/AppFrame.tsx b/app/src/components/AppFrame/AppFrame.tsx index 2caaa0dc..99bf192f 100644 --- a/app/src/components/AppFrame/AppFrame.tsx +++ b/app/src/components/AppFrame/AppFrame.tsx @@ -1,18 +1,35 @@ +import { useRouterState } from '@tanstack/react-router'; import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; +import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor'; import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { cn } from '@/lib/utils/cn'; +import { useStoryStore } from '@/stores/storyStore'; +import { useStory } from '@/lib/hooks/useStories'; interface AppFrameProps { children: React.ReactNode; } export function AppFrame({ children }: AppFrameProps) { + const routerState = useRouterState(); + const isStoriesRoute = routerState.location.pathname === '/stories'; + + const selectedStoryId = useStoryStore((state) => state.selectedStoryId); + const { data: story } = useStory(selectedStoryId); + + // Show track editor when on stories route with a selected story that has items + const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0; + return (
{children} - + {showTrackEditor ? ( + + ) : ( + + )}
); } diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index d6b843df..a03224c9 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -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 () => { diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index 3cdfd3eb..593c3c0f 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -1,5 +1,6 @@ +import { useMatchRoute } from '@tanstack/react-router'; 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 { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; @@ -11,24 +12,66 @@ import { SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/components/ui/use-toast'; 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 { useAddStoryItem, useStory } from '@/lib/hooks/useStories'; +import { cn } from '@/lib/utils/cn'; +import { useStoryStore } from '@/stores/storyStore'; import { useUIStore } from '@/stores/uiStore'; interface FloatingGenerateBoxProps { - isPlayerOpen: boolean; + isPlayerOpen?: boolean; + showVoiceSelector?: boolean; } -export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) { +export function FloatingGenerateBox({ + isPlayerOpen = false, + 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(null); + const textareaRef = useRef(null); + const matchRoute = useMatchRoute(); + const isStoriesRoute = matchRoute({ to: '/stories' }); + const selectedStoryId = useStoryStore((state) => state.selectedStoryId); + const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); + const { data: currentStory } = useStory(selectedStoryId); + const addStoryItem = useAddStoryItem(); + const { toast } = useToast(); + + // Calculate if track editor is visible (on stories route with items) + const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0; 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', + }); + } + } }, }); @@ -62,6 +105,66 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) }; }, [isExpanded]); + // Set first voice as default if none selected + useEffect(() => { + if (!selectedProfileId && profiles && profiles.length > 0) { + setSelectedProfileId(profiles[0].id); + } + }, [selectedProfileId, profiles, setSelectedProfileId]); + + // Get current form value to trigger resize when it changes + const formValue = form.watch(isInstructMode ? 'instruct' : 'text'); + + // Auto-resize textarea based on content (only when expanded) + useEffect(() => { + if (!isExpanded) { + // Reset textarea height after collapse animation completes + const timeoutId = setTimeout(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = '32px'; + textarea.style.overflowY = 'hidden'; + } + }, 200); // Wait for animation to complete + return () => clearTimeout(timeoutId); + } + + const textarea = textareaRef.current; + if (!textarea) return; + + const adjustHeight = () => { + textarea.style.height = 'auto'; + const scrollHeight = textarea.scrollHeight; + const minHeight = 100; // Expanded minimum + const maxHeight = 300; // Max height in pixels + const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight)); + textarea.style.height = `${targetHeight}px`; + + // Show scrollbar if content exceeds max height + if (scrollHeight > maxHeight) { + textarea.style.overflowY = 'auto'; + } else { + textarea.style.overflowY = 'hidden'; + } + }; + + // Small delay to let framer animation complete + const timeoutId = setTimeout(() => { + adjustHeight(); + }, 200); + + // Adjust on mount and when value changes + adjustHeight(); + + // Watch for input changes + textarea.addEventListener('input', adjustHeight); + + return () => { + clearTimeout(timeoutId); + textarea.removeEventListener('input', adjustHeight); + }; + }, [isExpanded]); + async function onSubmit(data: Parameters[0]) { await handleSubmit(data, selectedProfileId); } @@ -69,9 +172,21 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) return (
- + + {isInstructMode && ( + + Delivery instructions: + + )} ( -