diff --git a/lightly_studio_view/src/lib/components/VideoControls/VideoControls.stories.svelte b/lightly_studio_view/src/lib/components/VideoControls/VideoControls.stories.svelte index a7670c85c..0ca479523 100644 --- a/lightly_studio_view/src/lib/components/VideoControls/VideoControls.stories.svelte +++ b/lightly_studio_view/src/lib/components/VideoControls/VideoControls.stories.svelte @@ -7,7 +7,6 @@ component: VideoControls, tags: ['autodocs'] }); - // The bar owns no state; the Playground wires local state so the scrubber // and transport buttons actually respond. let currentTimeS = $state(15); diff --git a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte index c688ada6c..b4608e313 100644 --- a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte +++ b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte @@ -26,6 +26,7 @@ import { useCreateAnnotation } from '$lib/hooks/useCreateAnnotation/useCreateAnnotation'; import { useCreateLabel } from '$lib/hooks/useCreateLabel/useCreateLabel'; import { useSelectClassDialog } from '$lib/hooks/useSelectClassDialog/useSelectClassDialog'; + import { useDeleteAnnotation } from '$lib/hooks/useDeleteAnnotation/useDeleteAnnotation'; import { onMount } from 'svelte'; import { toast } from 'svelte-sonner'; import { routeHelpers } from '$lib/routes'; @@ -55,6 +56,7 @@ const { updateAnnotations } = useUpdateAnnotationsMutation({ collectionId: eventCollectionId }); const { createAnnotation } = useCreateAnnotation({ collectionId: eventCollectionId }); const { createLabel } = useCreateLabel({ collectionId: eventCollectionId }); + const { deleteAnnotation } = useDeleteAnnotation({ collectionId: eventCollectionId }); const annotationLabels = useAnnotationLabels(() => ({ collectionId: eventCollectionId })); const { @@ -118,6 +120,17 @@ } } + async function handleEventDelete(event: VideoEvent) { + try { + await deleteAnnotation(event.id); + onVideoUpdate(); + toast.success('Event deleted'); + } catch (error) { + toast.error('Failed to delete event. Please try again.'); + console.error('Error deleting event:', error); + } + } + const { currentFrame, frames: videoFrames, @@ -255,6 +268,7 @@ editableEvents={$isEditingMode} onEventResize={handleEventResize} onEventAdd={handleEventAdd} + onEventDelete={handleEventDelete} videoProps={{ muted: true, class: 'object-contain', diff --git a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.test.ts b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.test.ts index a19e1683f..0b0186ab2 100644 --- a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.test.ts +++ b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.test.ts @@ -57,6 +57,9 @@ vi.mock('$lib/hooks/useCreateLabel/useCreateLabel', () => ({ vi.mock('$lib/hooks/useAnnotationLabels/useAnnotationLabels', () => ({ useAnnotationLabels: vi.fn(() => ({ data: [] })) })); +vi.mock('$lib/hooks/useDeleteAnnotation/useDeleteAnnotation', () => ({ + useDeleteAnnotation: vi.fn(() => ({ deleteAnnotation: vi.fn() })) +})); import VideoDetails from './VideoDetails.svelte'; diff --git a/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.stories.svelte b/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.stories.svelte index 923a82257..4d34106ea 100644 --- a/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.stories.svelte +++ b/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.stories.svelte @@ -41,9 +41,6 @@ ]); const singleEvent = makeEvents([['Sprint', 3, 14]]); - - - @@ -82,7 +79,6 @@ currentTimeS: 5 }} /> - * ``` */ - import { Plus } from '@lucide/svelte'; + import { Plus, Trash2 } from '@lucide/svelte'; import { cn } from '$lib/utils/shadcn.js'; import { assignEventLanes, type VideoEvent } from '$lib/utils'; @@ -45,6 +45,8 @@ onResize?: (event: VideoEvent, startTimeS: number, endTimeS: number) => void; /** Called with a default span when the user requests a new event. */ onAddEvent?: (startTimeS: number, endTimeS: number) => void; + /** Called when the user deletes an event via its trash affordance. */ + onDelete?: (event: VideoEvent) => void; /** Heading shown above the timeline. */ title?: string; /** Whether to render the title/count header above the track. */ @@ -61,6 +63,7 @@ editable = false, onResize, onAddEvent, + onDelete, title = 'Events', showHeader = true, class: className @@ -228,7 +231,7 @@ )} {@const timeRange = `${formatTime(span.startTimeS)}–${formatTime(span.endTimeS)}`}
{event.label} + {#if editable && onDelete} + + {/if} + {#if editable} {#each ['start', 'end'] as const as edge (edge)}
{ // A 1s span can't start at 10s in a 10s video, so it is shifted back to end at 10s. expect(onAddEvent).toHaveBeenCalledWith(9, 10); }); + + it('shows the delete button only when editable with an onDelete handler', () => { + const { queryByTestId, rerender } = render(VideoEventTimeline, { + props: { events: [makeEvent({ id: 'a' })], durationS: 10, onDelete: vi.fn() } + }); + expect(queryByTestId('delete-event-button')).toBeFalsy(); + + rerender({ + events: [makeEvent({ id: 'a' })], + durationS: 10, + editable: true, + onDelete: vi.fn() + }); + expect(queryByTestId('delete-event-button')).toBeTruthy(); + }); + + it('calls onDelete without seeking when the trash button is clicked', async () => { + const onDelete = vi.fn(); + const onSeek = vi.fn(); + const event = makeEvent({ id: 'a', label: 'Clip' }); + const { getByTestId } = render(VideoEventTimeline, { + props: { events: [event], durationS: 10, editable: true, onDelete, onSeek } + }); + + await fireEvent.click(getByTestId('delete-event-button')); + expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })); + // The bar's seek click must not fire when deleting. + expect(onSeek).not.toHaveBeenCalled(); + }); }); diff --git a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte index c7f4e4dd9..986e5e88e 100644 --- a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -78,6 +78,9 @@ /** Called with a default span when the user adds a new event. */ onEventAdd?: (startTimeS: number, endTimeS: number) => void; + + /** Called when the user deletes an event. */ + onEventDelete?: (event: VideoEvent) => void; } let { @@ -90,7 +93,8 @@ durationS, editableEvents = false, onEventResize, - onEventAdd + onEventAdd, + onEventDelete }: VideoPlayerProps = $props(); const defaultVideoProps: HTMLVideoAttributes = { @@ -224,6 +228,7 @@ editable={editableEvents} onResize={onEventResize} onAddEvent={onEventAdd} + onDelete={onEventDelete} showHeader={false} /> {/if} diff --git a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.test.ts b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.test.ts index 2419b1da6..4f47a7b71 100644 --- a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.test.ts +++ b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.test.ts @@ -167,4 +167,28 @@ describe('VideoPlayer', () => { expect(getByTestId('video-event-timeline')).toBeTruthy(); expect(getByTestId('add-event-button')).toBeTruthy(); }); + + it('renders a delete affordance on events in edit mode', () => { + const { getByTestId } = render(VideoPlayer, { + props: { + src: 'test-video.mp4', + durationS: 10, + editableEvents: true, + onEventDelete: () => {}, + events: [ + { + id: 'e1', + annotationCollectionId: 'coll-1', + label: 'Jump', + startTimeS: 2, + endTimeS: 4, + color: 'rgba(10, 20, 30, 0.7)', + contrastColor: 'rgba(245, 235, 225, 0.7)' + } + ] + } + }); + + expect(getByTestId('delete-event-button')).toBeTruthy(); + }); });