mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user