diff --git a/lightly_studio_view/src/lib/components/VideoControls/VideoControls.svelte b/lightly_studio_view/src/lib/components/VideoControls/VideoControls.svelte index cf3a2c9a9..6e7b20e2b 100644 --- a/lightly_studio_view/src/lib/components/VideoControls/VideoControls.svelte +++ b/lightly_studio_view/src/lib/components/VideoControls/VideoControls.svelte @@ -4,6 +4,7 @@ * buttons. Full width so timeline overlays can share its coordinate system. * Owns no playback state; calls back on user intent (see {@link VideoPlayer}). */ + import type { Snippet } from 'svelte'; import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from '@lucide/svelte'; import { cn } from '$lib/utils/shadcn.js'; import { formatTime, clampPercent, timeFromClientX } from './VideoControls.helpers'; @@ -18,6 +19,12 @@ onSeek: (timeS: number) => void; onToggleMute: () => void; onToggleFullscreen: () => void; + /** + * Optional content (e.g. an event bar) rendered below the transport + * buttons. It sits in the same padded, full-width column as the scrubber, + * so overlays share the scrubber's coordinate system and line up exactly. + */ + children?: Snippet; class?: string; } @@ -31,6 +38,7 @@ onSeek, onToggleMute, onToggleFullscreen, + children, class: className }: VideoControlsProps = $props(); @@ -162,4 +170,5 @@ {/if} + {@render children?.()} diff --git a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte index 839889ffe..22efed279 100644 --- a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte +++ b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte @@ -9,7 +9,7 @@ VideoPlayer } from '$lib/components'; import { type FrameView, type SampleView, type VideoView } from '$lib/api/lightly_studio_local'; - import { getVideoURLById } from '$lib/utils'; + import { getVideoURLById, toVideoEvents } from '$lib/utils'; import VideoSampleMetadata from '../VideoSampleMetadata/VideoSampleMetadata.svelte'; import SampleDetailsCaptionSegment from '../SampleDetails/SampleDetailsCaptionsSegment/SampleDetailsCaptionSegment.svelte'; import { useVideoFrames } from '$lib/hooks/useVideoFrames/useVideoFrames'; @@ -31,6 +31,9 @@ let videoEl: HTMLVideoElement | null = $state(null); let frameRequestId: number | null = $state(null); + // Imported events: classification annotations on the video carrying a time span. + const videoEvents = $derived(toVideoEvents(video.sample.annotations ?? [])); + const { currentFrame, frames: videoFrames, @@ -163,6 +166,8 @@ src={getVideoURLById(video.sample_id)} bind:videoEl {startTimeS} + events={videoEvents} + durationS={video.duration_s ?? undefined} videoProps={{ muted: true, class: 'object-contain', diff --git a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.stories.svelte b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.stories.svelte index c922436b0..9ac29a455 100644 --- a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.stories.svelte +++ b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.stories.svelte @@ -1,6 +1,7 @@ @@ -22,6 +40,17 @@ + +
+ +
+
+
diff --git a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte index 4f7eed91e..29826c21c 100644 --- a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -22,7 +22,9 @@ import { cn } from '$lib/utils/shadcn.js'; import { MEDIA_ERROR_MESSAGES } from './errors'; import VideoControls from '../VideoControls/VideoControls.svelte'; + import VideoEventTimeline from '../VideoEventTimeline/VideoEventTimeline.svelte'; import { useVideoPlayback } from './useVideoPlayback.svelte'; + import type { VideoEvent } from '$lib/utils'; interface VideoPlayerProps { /** @@ -54,6 +56,19 @@ * @default 0 */ startTimeS?: number | null; + + /** + * Time-bounded events rendered as a clickable bar aligned with the + * scrubber. When empty, no event bar is shown. + */ + events?: VideoEvent[]; + + /** + * Total video duration in seconds, used to position the event bars and + * the scrubber. Falls back to the element's own duration once metadata + * loads. + */ + durationS?: number; } let { @@ -61,7 +76,9 @@ videoEl = $bindable(null), videoProps = {}, hoverClass = 'outline outline-2 outline-blue-500', - startTimeS = 0 + startTimeS = 0, + events = [], + durationS }: VideoPlayerProps = $props(); const defaultVideoProps: HTMLVideoAttributes = { @@ -102,6 +119,11 @@ initialMuted: defaultVideoProps.muted ?? true }); + // Prefer an explicit duration (known before metadata loads) for the scrubber + // and the event bar, so both share the same coordinate system. + const effectiveDurationS = $derived(durationS ?? playback.durationS); + const showEvents = $derived(events.length > 0); + function handleVideoError() { const errorCode = videoEl?.error?.code; sourceLoadError = errorCode @@ -171,7 +193,7 @@ + > + {#if showEvents} + + {/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 f809fc1dc..3f565f29a 100644 --- a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.test.ts +++ b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.test.ts @@ -125,4 +125,32 @@ describe('VideoPlayer', () => { expect(getByRole('slider')).toBeTruthy(); expect(getByLabelText('Play')).toBeTruthy(); }); + + it('should not render an event bar when no events are given', () => { + const { queryByTestId } = render(VideoPlayer, { props: { src: 'test-video.mp4' } }); + expect(queryByTestId('video-event-timeline')).toBeFalsy(); + }); + + it('should render an event bar in the controls when events are given', () => { + const { getByTestId, getByText } = render(VideoPlayer, { + props: { + src: 'test-video.mp4', + durationS: 10, + 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('video-event-timeline')).toBeTruthy(); + expect(getByText('Jump')).toBeTruthy(); + }); });