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 (