Claude
Skills
Sign in
Back

video-timeline

Included with Lifetime
$97 forever

React video player with engagement heatmap timeline and clickable hotspot markers. Use this skill when the user says "add video timeline", "video heatmap", "clip finder ui", "video hotspots", or "engagement timeline".

Design

What this skill does


# Video Timeline Skill

A self-contained video player component with a custom heatmap timeline and clickable hotspot markers. The timeline bar uses a color gradient (gray / amber / green) derived from each hotspot's score. Clicking a marker seeks the video, shows a score-breakdown panel below, and fires a callback. No external video libraries — uses the browser HTML5 `<video>` API directly.

## Prerequisites

- Next.js app with App Router (no `src/` directory)
- `add-shadcn` skill applied

## Installation

No additional packages required. Uses the browser HTML5 video API and `@phosphor-icons/react` (already installed by the `add-shadcn` preset).

## What Gets Created

```
components/
└── video-timeline/
    ├── video-timeline.tsx      # Full component (use client)
    └── use-video-timeline.ts   # Hook for video state
```

## Setup Steps

### Step 1: Create `components/video-timeline/use-video-timeline.ts`

```typescript
import { useState, useCallback, useEffect, type RefObject } from "react";

type UseVideoTimelineReturn = {
  currentTime: number;
  duration: number;
  isPlaying: boolean;
  volume: number;
  play: () => void;
  pause: () => void;
  seek: (time: number) => void;
  setVolume: (volume: number) => void;
};

export function useVideoTimeline(
  videoRef: RefObject<HTMLVideoElement | null>
): UseVideoTimelineReturn {
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [isPlaying, setIsPlaying] = useState(false);
  const [volume, setVolumeState] = useState(1);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    const onTimeUpdate = () => setCurrentTime(video.currentTime);
    const onDurationChange = () => setDuration(video.duration || 0);
    const onPlay = () => setIsPlaying(true);
    const onPause = () => setIsPlaying(false);
    const onVolumeChange = () => setVolumeState(video.volume);

    video.addEventListener("timeupdate", onTimeUpdate);
    video.addEventListener("durationchange", onDurationChange);
    video.addEventListener("loadedmetadata", onDurationChange);
    video.addEventListener("play", onPlay);
    video.addEventListener("pause", onPause);
    video.addEventListener("volumechange", onVolumeChange);

    return () => {
      video.removeEventListener("timeupdate", onTimeUpdate);
      video.removeEventListener("durationchange", onDurationChange);
      video.removeEventListener("loadedmetadata", onDurationChange);
      video.removeEventListener("play", onPlay);
      video.removeEventListener("pause", onPause);
      video.removeEventListener("volumechange", onVolumeChange);
    };
  }, [videoRef]);

  const play = useCallback(() => {
    videoRef.current?.play();
  }, [videoRef]);

  const pause = useCallback(() => {
    videoRef.current?.pause();
  }, [videoRef]);

  const seek = useCallback(
    (time: number) => {
      const video = videoRef.current;
      if (!video) return;
      video.currentTime = Math.min(Math.max(0, time), video.duration || 0);
    },
    [videoRef]
  );

  const setVolume = useCallback(
    (vol: number) => {
      const video = videoRef.current;
      if (!video) return;
      video.volume = Math.min(Math.max(0, vol), 1);
    },
    [videoRef]
  );

  return { currentTime, duration, isPlaying, volume, play, pause, seek, setVolume };
}
```

### Step 2: Create `components/video-timeline/video-timeline.tsx`

```typescript
"use client";

import { useRef, useState, useCallback, useId } from "react";
import {
  Play,
  Pause,
  SpeakerHigh,
  SpeakerX,
  ArrowsOut,
} from "@phosphor-icons/react";
import { useVideoTimeline } from "./use-video-timeline";
import { cn } from "@/lib/utils";

export type VideoHotspot = {
  id: string;
  timestamp: number; // seconds
  score: number; // 0-1
  label: string;
  axes: {
    emotionalPeak: number;
    infoDensity: number;
    surprise: number;
    standAlone: number;
  };
};

type VideoTimelineProps = {
  src: string;
  hotspots: VideoHotspot[];
  onHotspotClick?: (hotspot: VideoHotspot) => void;
  onTimeUpdate?: (currentTime: number, duration: number) => void;
  className?: string;
};

function formatTime(seconds: number): string {
  if (!isFinite(seconds)) return "0:00";
  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60);
  return `${m}:${s.toString().padStart(2, "0")}`;
}

function scoreToColor(score: number): string {
  if (score >= 0.6) return "#22c55e"; // green-500
  if (score >= 0.3) return "#f59e0b"; // amber-500
  return "#6b7280"; // gray-500
}

function scoreToLabel(score: number): string {
  if (score >= 0.6) return "High";
  if (score >= 0.3) return "Medium";
  return "Low";
}

type AxisBarProps = {
  label: string;
  value: number;
};

function AxisBar({ label, value }: AxisBarProps) {
  return (
    <div className="flex flex-col gap-1">
      <div className="flex justify-between text-xs text-muted-foreground">
        <span>{label}</span>
        <span>{Math.round(value * 100)}%</span>
      </div>
      <div className="h-1.5 bg-muted rounded-full overflow-hidden">
        <div
          className="h-full rounded-full bg-primary transition-all duration-300"
          style={{ width: `${value * 100}%` }}
        />
      </div>
    </div>
  );
}

export function VideoTimeline({
  src,
  hotspots,
  onHotspotClick,
  onTimeUpdate,
  className,
}: VideoTimelineProps) {
  const markerId = useId();
  const videoRef = useRef<HTMLVideoElement>(null);
  const timelineRef = useRef<HTMLDivElement>(null);
  const [selectedHotspot, setSelectedHotspot] = useState<VideoHotspot | null>(null);
  const [isMuted, setIsMuted] = useState(false);

  const { currentTime, duration, isPlaying, volume, play, pause, seek, setVolume } =
    useVideoTimeline(videoRef);

  const handleTimeUpdate = useCallback(() => {
    if (onTimeUpdate && videoRef.current) {
      onTimeUpdate(videoRef.current.currentTime, videoRef.current.duration || 0);
    }
  }, [onTimeUpdate]);

  const handleTimelineClick = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      const rect = timelineRef.current?.getBoundingClientRect();
      if (!rect || duration === 0) return;
      const ratio = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
      seek(ratio * duration);
    },
    [duration, seek]
  );

  const handleHotspotClick = useCallback(
    (hotspot: VideoHotspot) => {
      seek(hotspot.timestamp);
      setSelectedHotspot((prev) => (prev?.id === hotspot.id ? null : hotspot));
      onHotspotClick?.(hotspot);
    },
    [seek, onHotspotClick]
  );

  const toggleMute = useCallback(() => {
    const video = videoRef.current;
    if (!video) return;
    video.muted = !video.muted;
    setIsMuted(video.muted);
  }, []);

  const handleFullscreen = useCallback(() => {
    videoRef.current?.requestFullscreen?.();
  }, []);

  const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;

  return (
    <div className={cn("flex flex-col gap-0 bg-card border border-border rounded-xl overflow-hidden", className)}>
      {/* Video element */}
      <video
        ref={videoRef}
        src={src}
        className="w-full aspect-video bg-black"
        preload="metadata"
        onTimeUpdate={handleTimeUpdate}
        playsInline
      />

      {/* Custom controls */}
      <div className="flex items-center gap-3 px-4 py-2 bg-card border-t border-border">
        <button
          type="button"
          onClick={isPlaying ? pause : play}
          className="text-foreground hover:text-primary transition-colors"
          aria-label={isPlaying ? "Pause" : "Play"}
        >
          {isPlaying ? <Pause size={20} weight="fill" /> : <Play size={20} weight="fill" />}
        </button>

        <span className="text-xs text-muted-foreground tabular-nums min-w-[80px]">
          {formatTime(currentTime)} / {formatTime(duration)}
        </span>

        <button
          type="button"
          onClick={toggleMute}
          className="text-
Files: 1
Size: 16.5 KB
Complexity: 25/100
Category: Design

Related in Design