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',
+ });
+ },
+ },
+ )
+ }
+ />
+ )}