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
+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]);
}