diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 0ef3e981..2ee2e5f6 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -11,6 +11,8 @@ import { Scissors, Square, Trash2, + Volume2, + VolumeX, } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; @@ -21,6 +23,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Slider } from '@/components/ui/slider'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { StoryItemDetail } from '@/lib/api/types'; @@ -31,6 +35,7 @@ import { useSetStoryItemVersion, useSplitStoryItem, useTrimStoryItem, + useUpdateStoryItemVolume, } from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; import { useGenerationStore } from '@/stores/generationStore'; @@ -120,6 +125,66 @@ function ClipWaveform({ ); } +// Per-clip volume popover. Local state drives the slider during a drag so +// each pointer-move pixel doesn't fire a PATCH; commits on release. +function ClipVolumePopover({ + storyId, + itemId, + volume, + onChange, +}: { + storyId: string; + itemId: string; + volume: number; + onChange: (value: number) => void; +}) { + const [localVolume, setLocalVolume] = useState(volume); + // Re-sync when the selected clip changes or the persisted value updates + // out-of-band (split/duplicate carry the value forward). + useEffect(() => { + setLocalVolume(volume); + }, [volume, itemId, storyId]); + + const display = Math.round(localVolume * 100); + const Icon = localVolume === 0 ? VolumeX : Volume2; + + return ( + + + + + +
+ Volume + {display}% +
+ setLocalVolume(v / 100)} + onValueCommit={([v]) => onChange(v / 100)} + min={0} + max={200} + step={1} + aria-label="Clip volume" + /> +
+ 0% + 100% + 200% +
+
+
+ ); +} + interface StoryTrackEditorProps { storyId: string; items: StoryItemDetail[]; @@ -157,6 +222,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const duplicateItem = useDuplicateStoryItem(); const removeItem = useRemoveStoryItem(); const setItemVersion = useSetStoryItemVersion(); + const updateVolume = useUpdateStoryItemVolume(); const { toast } = useToast(); const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration); @@ -1045,6 +1111,31 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { > + {selectedItem && ( + + updateVolume.mutate( + { + storyId, + itemId: selectedItem.id, + data: { volume: value }, + }, + { + onError: (error) => { + toast({ + title: 'Failed to update volume', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + }, + }, + ) + } + /> + )}