mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
Refactor FloatingGenerateBox and StoriesTab for improved layout and interaction
- Adjusted FloatingGenerateBox positioning to align with the story list, ensuring consistent UI across different routes. - Modified StoriesTab layout to enhance responsiveness, including setting a maximum width for the story list and adjusting the right column for better content display. - Streamlined StoryChatItem interaction by simplifying the play functionality, allowing double-click to trigger playback directly from the text area. - Enhanced StoryContent component by cleaning up unused playback controls and improving overall structure for better readability.
This commit is contained in:
@@ -110,7 +110,8 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector =
|
|||||||
className={cn(
|
className={cn(
|
||||||
'fixed right-auto',
|
'fixed right-auto',
|
||||||
isStoriesRoute
|
isStoriesRoute
|
||||||
? 'left-[calc(5rem+2rem+((100%-5rem-4rem)/2)+1.5rem)] w-[calc((100%-5rem-4rem)/2-1rem)]'
|
// Position aligned with story list: after sidebar + padding, width 360px
|
||||||
|
? 'left-[calc(5rem+2rem)] w-[360px]'
|
||||||
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ export function StoriesTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-2 gap-6 overflow-hidden relative">
|
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
|
||||||
{/* Left Column - Story List */}
|
{/* Left Column - Story List */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden w-full max-w-[360px] shrink-0">
|
||||||
<StoryList />
|
<StoryList />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Story Content */}
|
{/* Right Column - Story Content */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
|
||||||
<StoryContent />
|
<StoryContent />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -88,24 +88,12 @@ export function StoryChatItem({
|
|||||||
{formatTime(itemStartMs)}
|
{formatTime(itemStartMs)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Textarea
|
||||||
type="button"
|
value={item.text}
|
||||||
className="w-full text-left cursor-pointer"
|
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
|
||||||
onClick={(e) => {
|
readOnly
|
||||||
// Don't trigger play if clicking on textarea or if text is selected
|
onDoubleClick={handlePlay}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
@@ -13,16 +13,15 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { Download, Pause, Play } from 'lucide-react';
|
import { Download } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import {
|
import {
|
||||||
useStory,
|
|
||||||
useRemoveStoryItem,
|
|
||||||
useExportStoryAudio,
|
useExportStoryAudio,
|
||||||
|
useRemoveStoryItem,
|
||||||
useReorderStoryItems,
|
useReorderStoryItems,
|
||||||
|
useStory,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
@@ -61,15 +60,10 @@ export function StoryContent() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Playback state
|
// Playback state (for auto-scroll and item highlighting)
|
||||||
const isPlaying = useStoryStore((state) => state.isPlaying);
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
const totalDurationMs = useStoryStore((state) => state.totalDurationMs);
|
|
||||||
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
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);
|
|
||||||
|
|
||||||
// Refs for auto-scrolling to playing item
|
// Refs for auto-scrolling to playing item
|
||||||
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
|
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
@@ -169,32 +163,6 @@ export function StoryContent() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = () => {
|
const handleExportAudio = () => {
|
||||||
if (!story) return;
|
if (!story) return;
|
||||||
|
|
||||||
@@ -248,67 +216,23 @@ export function StoryContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-4 mb-4 px-1">
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||||
<h2 className="text-2xl font-bold">{story.name}</h2>
|
{story.description && (
|
||||||
{story.description && (
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Playback Controls */}
|
|
||||||
{story.items.length > 0 && (
|
{story.items.length > 0 && (
|
||||||
<div className="flex items-center gap-4">
|
<Button
|
||||||
<span className="text-xs text-muted-foreground tabular-nums min-w-16">
|
variant="outline"
|
||||||
{formatTime(currentTimeMs)}
|
size="sm"
|
||||||
</span>
|
onClick={handleExportAudio}
|
||||||
<Slider
|
disabled={exportAudio.isPending}
|
||||||
value={[currentTimeMs]}
|
>
|
||||||
max={totalDurationMs || 1}
|
<Download className="mr-2 h-4 w-4" />
|
||||||
step={10}
|
Export Audio
|
||||||
onValueChange={handleSeek}
|
</Button>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,60 @@
|
|||||||
import { GripHorizontal, Minus, Plus } from 'lucide-react';
|
import { GripHorizontal, Minus, Pause, Play, Plus, Square } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useMoveStoryItem } from '@/lib/hooks/useStories';
|
import { useMoveStoryItem } from '@/lib/hooks/useStories';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
// Clip waveform component
|
||||||
|
function ClipWaveform({ generationId, width }: { generationId: string; width: number }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || width < 20) return;
|
||||||
|
|
||||||
|
// Get CSS colors
|
||||||
|
const root = document.documentElement;
|
||||||
|
const getCSSVar = (varName: string) => {
|
||||||
|
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||||
|
return value ? `hsl(${value})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const waveColor = getCSSVar('--accent-foreground');
|
||||||
|
|
||||||
|
const wavesurfer = WaveSurfer.create({
|
||||||
|
container: containerRef.current,
|
||||||
|
waveColor,
|
||||||
|
progressColor: waveColor,
|
||||||
|
cursorWidth: 0,
|
||||||
|
barWidth: 1,
|
||||||
|
barRadius: 1,
|
||||||
|
barGap: 1,
|
||||||
|
height: 28,
|
||||||
|
normalize: true,
|
||||||
|
interact: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
wavesurferRef.current = wavesurfer;
|
||||||
|
|
||||||
|
const audioUrl = apiClient.getAudioUrl(generationId);
|
||||||
|
wavesurfer.load(audioUrl).catch(() => {
|
||||||
|
// Ignore load errors
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
wavesurfer.destroy();
|
||||||
|
wavesurferRef.current = null;
|
||||||
|
};
|
||||||
|
}, [generationId, width]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="w-full h-full opacity-60" />;
|
||||||
|
}
|
||||||
|
|
||||||
interface StoryTrackEditorProps {
|
interface StoryTrackEditorProps {
|
||||||
storyId: string;
|
storyId: string;
|
||||||
items: StoryItemDetail[];
|
items: StoryItemDetail[];
|
||||||
@@ -39,11 +87,34 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
|
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
|
const storeTotalDurationMs = useStoryStore((state) => state.totalDurationMs);
|
||||||
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
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);
|
const seek = useStoryStore((state) => state.seek);
|
||||||
|
|
||||||
const isActiveStory = playbackStoryId === storyId;
|
const isActiveStory = playbackStoryId === storyId;
|
||||||
|
const isCurrentlyPlaying = isPlaying && isActiveStory;
|
||||||
|
|
||||||
|
// Sort items by start time for play
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
return [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (isCurrentlyPlaying) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
play(storyId, sortedItems);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate unique tracks from items, always showing at least 3 default tracks
|
// Calculate unique tracks from items, always showing at least 3 default tracks
|
||||||
const tracks = useMemo(() => {
|
const tracks = useMemo(() => {
|
||||||
@@ -286,84 +357,122 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/30 mt-2">
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
|
||||||
<span className="text-xs text-muted-foreground">Zoom:</span>
|
{/* Play controls - left side */}
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
<div className="flex items-center gap-2">
|
||||||
<Minus className="h-3 w-3" />
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
|
||||||
</Button>
|
{isCurrentlyPlaying ? (
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
<Pause className="h-4 w-4" />
|
||||||
<Plus className="h-3 w-3" />
|
) : (
|
||||||
</Button>
|
<Play className="h-4 w-4" />
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
)}
|
||||||
{Math.round(pixelsPerSecond)}px/s
|
</Button>
|
||||||
</span>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleStop} disabled={!isActiveStory}>
|
||||||
</div>
|
<Square className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
{/* Timeline container - drag handlers are intentional for drag-and-drop UX */}
|
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
{formatTime(isActiveStory ? currentTimeMs : 0)} / {formatTime(isActiveStory ? storeTotalDurationMs : 0)}
|
||||||
<div
|
</span>
|
||||||
ref={tracksRef}
|
|
||||||
className="overflow-auto relative"
|
|
||||||
style={{ height: `${timelineContainerHeight}px` }}
|
|
||||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
|
||||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
|
||||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
|
||||||
>
|
|
||||||
{/* Time ruler */}
|
|
||||||
<div
|
|
||||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10"
|
|
||||||
style={{ width: `${timelineWidth}px` }}
|
|
||||||
>
|
|
||||||
{timeMarkers.map((ms) => (
|
|
||||||
<div
|
|
||||||
key={ms}
|
|
||||||
className="absolute top-0 h-full flex flex-col justify-end"
|
|
||||||
style={{ left: `${msToPixels(ms)}px` }}
|
|
||||||
>
|
|
||||||
<div className="h-2 w-px bg-border" />
|
|
||||||
<span className="text-[10px] text-muted-foreground ml-1 select-none">
|
|
||||||
{formatTime(ms)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tracks area */}
|
{/* Zoom controls - right side */}
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="relative"
|
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||||
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
||||||
>
|
<Minus className="h-3 w-3" />
|
||||||
{/* Track backgrounds */}
|
</Button>
|
||||||
{tracks.map((trackNumber, index) => (
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
||||||
<div
|
<Plus className="h-3 w-3" />
|
||||||
key={trackNumber}
|
</Button>
|
||||||
className={cn(
|
</div>
|
||||||
'absolute left-0 right-0 border-b',
|
</div>
|
||||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
top: `${index * TRACK_HEIGHT}px`,
|
|
||||||
height: `${TRACK_HEIGHT}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground select-none">
|
|
||||||
Track {trackNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Click area for seeking - z-index lower than clips */}
|
{/* Timeline container with track labels sidebar */}
|
||||||
<button
|
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
|
||||||
type="button"
|
{/* Track labels sidebar - fixed width */}
|
||||||
className="absolute inset-0 z-0 cursor-pointer"
|
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
|
||||||
onClick={handleTimelineClick}
|
{/* Spacer for time ruler */}
|
||||||
aria-label="Seek timeline"
|
<div className="h-6 border-b bg-muted/30" />
|
||||||
/>
|
{/* Track labels */}
|
||||||
|
<div style={{ height: `${tracksAreaHeight}px` }}>
|
||||||
|
{tracks.map((trackNumber, index) => (
|
||||||
|
<div
|
||||||
|
key={trackNumber}
|
||||||
|
className={cn(
|
||||||
|
'border-b flex items-center justify-center',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
||||||
|
)}
|
||||||
|
style={{ height: `${TRACK_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-muted-foreground select-none">
|
||||||
|
{trackNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable timeline area */}
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||||
|
<div
|
||||||
|
ref={tracksRef}
|
||||||
|
className="overflow-auto relative flex-1"
|
||||||
|
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||||
|
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||||
|
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||||
|
>
|
||||||
|
{/* Time ruler */}
|
||||||
|
<div
|
||||||
|
className="h-6 border-b bg-muted/20 sticky top-0 z-10"
|
||||||
|
style={{ width: `${timelineWidth}px` }}
|
||||||
|
>
|
||||||
|
{timeMarkers.map((ms) => (
|
||||||
|
<div
|
||||||
|
key={ms}
|
||||||
|
className="absolute top-0 h-full flex flex-col justify-end"
|
||||||
|
style={{ left: `${msToPixels(ms)}px` }}
|
||||||
|
>
|
||||||
|
<div className="h-2 w-px bg-border" />
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1 select-none">
|
||||||
|
{formatTime(ms)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tracks area */}
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
||||||
|
>
|
||||||
|
{/* Track backgrounds */}
|
||||||
|
{tracks.map((trackNumber, index) => (
|
||||||
|
<div
|
||||||
|
key={trackNumber}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 right-0 border-b',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${index * TRACK_HEIGHT}px`,
|
||||||
|
height: `${TRACK_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Click area for seeking - z-index lower than clips */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 z-0 cursor-pointer"
|
||||||
|
onClick={handleTimelineClick}
|
||||||
|
aria-label="Seek timeline"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Audio clips */}
|
{/* Audio clips */}
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isDragging = draggingItem === item.generation_id;
|
const isDragging = draggingItem === item.generation_id;
|
||||||
const style = getClipStyle(item);
|
const style = getClipStyle(item);
|
||||||
|
const clipWidth = msToPixels(item.duration * 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -372,20 +481,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'absolute rounded cursor-move select-none overflow-hidden z-10',
|
'absolute rounded cursor-move select-none overflow-hidden z-10',
|
||||||
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
|
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
|
||||||
'flex items-center px-2 text-left',
|
'flex flex-col justify-center',
|
||||||
isDragging && 'opacity-80 shadow-lg z-20',
|
isDragging && 'opacity-80 shadow-lg z-20',
|
||||||
!isDragging && 'transition-all duration-100'
|
!isDragging && 'transition-all duration-100'
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
onMouseDown={(e) => handleDragStart(e, item)}
|
onMouseDown={(e) => handleDragStart(e, item)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
{/* Clip label */}
|
||||||
<p className="text-[10px] font-medium text-accent-foreground truncate">
|
<div className="absolute top-0 left-1 right-1 z-10">
|
||||||
|
<p className="text-[9px] font-medium text-accent-foreground truncate">
|
||||||
{item.profile_name}
|
{item.profile_name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[9px] text-accent-foreground/70 truncate">
|
</div>
|
||||||
{item.text.substring(0, 30)}...
|
{/* Waveform */}
|
||||||
</p>
|
<div className="absolute inset-0 top-3">
|
||||||
|
<ClipWaveform generationId={item.generation_id} width={clipWidth} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -394,13 +505,14 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
{/* Playhead */}
|
{/* Playhead */}
|
||||||
{isActiveStory && (
|
{isActiveStory && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 w-px bg-primary z-30 pointer-events-none"
|
className="absolute top-0 bottom-0 w-1 bg-accent z-30 pointer-events-none rounded-full"
|
||||||
style={{ left: `${playheadLeft}px` }}
|
style={{ left: `${playheadLeft}px` }}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-primary" />
|
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-accent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface StoryPlaybackState {
|
|||||||
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TRACK_EDITOR_HEIGHT = 200;
|
const DEFAULT_TRACK_EDITOR_HEIGHT = 250;
|
||||||
|
|
||||||
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
||||||
// Selection
|
// Selection
|
||||||
|
|||||||
Reference in New Issue
Block a user