Claude
Skills
Sign in
Back

video-player

Included with Lifetime
$97 forever

MUX Player React components — responsive video player, chapter navigation, captions toggle, thumbnail hover previews, and video cards. Use this skill when the user says "add video player", "setup mux player", "video components", "add player", or "video-player".

Image & Video

What this skill does


# Video Player

MUX Player React components with shadcn/ui styling. Includes a responsive player wrapper, chapter navigation sidebar, caption controls, thumbnail hover previews, and video list cards. All components are designed for the App Router with "use client" directives where hooks are used.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `stream-mux` skill applied (MUX backend infrastructure)
- `add-shadcn` skill applied (design system with Tailwind v4)
- shadcn/ui components: `card`, `button`, `badge`, `scroll-area`, `dropdown-menu`, `toggle`

## Installation

```bash
bun add @mux/mux-player-react
bunx shadcn@latest add card button badge scroll-area dropdown-menu toggle
```

## What Gets Created

```
src/
└── components/
    └── video/
        ├── mux-player.tsx             # MUX Player React wrapper
        ├── video-chapters.tsx         # Chapter navigation sidebar
        ├── video-captions-toggle.tsx  # Caption on/off + language selector
        ├── video-thumbnail-hover.tsx  # Thumbnail preview strip on hover
        └── video-card.tsx             # Video card for lists
```

## Setup Steps

### Step 1: Create `src/components/video/mux-player.tsx`

```tsx
"use client";

import { useRef, useCallback } from "react";
import MuxPlayerElement from "@mux/mux-player-react";
import type { MuxPlayerRefAttributes } from "@mux/mux-player-react";
import { cn } from "@/lib/utils";

type MuxPlayerProps = {
  playbackId: string;
  title?: string;
  autoPlay?: boolean;
  muted?: boolean;
  accentColor?: string;
  thumbnailTime?: number;
  signedToken?: string;
  startTime?: number;
  className?: string;
  onTimeUpdate?: (currentTime: number) => void;
  onEnded?: () => void;
  onError?: (error: Error) => void;
};

type MuxPlayerRef = {
  seekTo: (time: number) => void;
  play: () => void;
  pause: () => void;
  getCurrentTime: () => number;
};

function MuxPlayer({
  playbackId,
  title,
  autoPlay = false,
  muted = false,
  accentColor,
  thumbnailTime,
  signedToken,
  startTime,
  className,
  onTimeUpdate,
  onEnded,
  onError,
}: MuxPlayerProps) {
  const playerRef = useRef<MuxPlayerRefAttributes>(null);

  const handleTimeUpdate = useCallback(() => {
    if (onTimeUpdate && playerRef.current) {
      onTimeUpdate(playerRef.current.currentTime);
    }
  }, [onTimeUpdate]);

  const handleError = useCallback(
    (event: Event) => {
      if (onError) {
        const message =
          event instanceof ErrorEvent
            ? event.message
            : "Video playback error";
        onError(new Error(message));
      }
    },
    [onError]
  );

  return (
    <div
      className={cn(
        "relative w-full overflow-hidden rounded-lg bg-black",
        "aspect-video",
        className
      )}
    >
      <MuxPlayerElement
        ref={playerRef}
        playbackId={playbackId}
        metadata={{
          video_title: title ?? "",
        }}
        autoPlay={autoPlay ? "muted" : undefined}
        muted={muted}
        accentColor={accentColor}
        thumbnailTime={thumbnailTime}
        tokens={signedToken ? { playback: signedToken } : undefined}
        startTime={startTime}
        streamType="on-demand"
        onTimeUpdate={handleTimeUpdate}
        onEnded={onEnded}
        onError={handleError}
        style={{
          width: "100%",
          height: "100%",
          "--media-object-fit": "contain",
        }}
      />
    </div>
  );
}

export { MuxPlayer };
export type { MuxPlayerProps, MuxPlayerRef };
```

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

```tsx
"use client";

import { useState, useId } from "react";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";

type Chapter = {
  title: string;
  startTime: number;
};

type VideoChaptersProps = {
  chapters: Chapter[];
  currentTime?: number;
  onChapterClick: (startTime: number) => void;
  className?: string;
};

function formatTimestamp(seconds: number): string {
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins}:${secs.toString().padStart(2, "0")}`;
}

function getCurrentChapterIndex(
  chapters: Chapter[],
  currentTime: number
): number {
  let activeIndex = 0;
  for (let i = 0; i < chapters.length; i++) {
    if (currentTime >= chapters[i].startTime) {
      activeIndex = i;
    }
  }
  return activeIndex;
}

function VideoChapters({
  chapters,
  currentTime = 0,
  onChapterClick,
  className,
}: VideoChaptersProps) {
  const listId = useId();
  const activeIndex = getCurrentChapterIndex(chapters, currentTime);

  return (
    <ScrollArea
      className={cn(
        "w-full rounded-lg border bg-card",
        className
      )}
    >
      <div className="p-2">
        <h3 className="mb-2 px-2 text-sm font-semibold text-muted-foreground">
          Chapters
        </h3>
        <ul className="space-y-0.5">
          {chapters.map((chapter, index) => (
            <li key={`${listId}-${chapter.startTime}`}>
              <button
                type="button"
                onClick={() => onChapterClick(chapter.startTime)}
                className={cn(
                  "flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm transition-colors",
                  "hover:bg-accent hover:text-accent-foreground",
                  index === activeIndex &&
                    "bg-accent text-accent-foreground font-medium"
                )}
              >
                <span className="shrink-0 font-mono text-xs text-muted-foreground">
                  {formatTimestamp(chapter.startTime)}
                </span>
                <span className="truncate">{chapter.title}</span>
              </button>
            </li>
          ))}
        </ul>
      </div>
    </ScrollArea>
  );
}

export { VideoChapters };
export type { VideoChaptersProps, Chapter };
```

### Step 3: Create `src/components/video/video-captions-toggle.tsx`

```tsx
"use client";

import { useCallback, useId } from "react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";

type CaptionTrack = {
  language: string;
  label: string;
};

type VideoCaptionsToggleProps = {
  tracks: CaptionTrack[];
  activeTrack: string | null;
  onToggle: (enabled: boolean) => void;
  onSelectTrack: (language: string) => void;
  className?: string;
};

function CaptionsIcon({ enabled }: { enabled: boolean }) {
  return (
    <svg
      width="20"
      height="20"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={cn(enabled ? "text-foreground" : "text-muted-foreground")}
    >
      <rect x="2" y="4" width="20" height="16" rx="2" />
      <path d="M7 12h4" />
      <path d="M13 12h4" />
      <path d="M7 16h10" />
    </svg>
  );
}

function VideoCaptionsToggle({
  tracks,
  activeTrack,
  onToggle,
  onSelectTrack,
  className,
}: VideoCaptionsToggleProps) {
  const menuId = useId();
  const isEnabled = activeTrack !== null;

  const handleToggle = useCallback(() => {
    onToggle(!isEnabled);
  }, [isEnabled, onToggle]);

  if (tracks.length === 0) {
    return null;
  }

  if (tracks.length === 1) {
    return (
      <Button
        variant={isEnabled ? "default" : "outline"}
        size="sm"
        onClick={handleToggle}
        className={cn("gap-2", className)}
        aria-label={isEnabled ? "Disable captions" : "Enable captions"}
      >
        <CaptionsIcon enabled={isEnabled} />
        <span className="text-xs">CC</span>
      </Button>
    );
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          variant={isEnabled ? "default" : "outline"}
          size="sm"
          className={cn("gap-2", className)}
          aria-label="Caption setti
Files: 1
Size: 24.0 KB
Complexity: 33/100
Category: Image & Video

Related in Image & Video