Video player

Headless React video player built on the native HTMLVideoElement. Includes play/pause, volume, mute, scrub timeline, playback rate, picture-in-picture, captions, and a fallback poster. Loom / YouTube / Vimeo URLs are auto-converted to their embed iframe.

0
1
0:00 / 0:00

Paste a direct MP4 / WebM URL, a share link from a service that publishes og:video (Screen Studio, Wistia, etc.), or a Loom / YouTube / Vimeo embed URL. Press Enter or blur the field to load it.

Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/video-player.json
Dependencies
The CLI install (`shadcn@latest add …video-player.json`) pulls everything below automatically. If you're copying the source manually, install these first.

npm packages

  • lucide-reactPlay / Pause / Volume / Caption / Maximize / Speed / PIP icons.

Squad-SDK primitives

The CLI install pulls these automatically. Manual installs need to add them first.

  • buttonUsed for every control bar button (play, mute, captions, speed, PIP, fullscreen). manifest
  • sliderDrives the timeline scrubber and the volume slider. manifest
  • dropdown-menuPowers the playback-speed and captions menus. manifest
Optional: og:video resolver
The component never makes network requests on its own — `resolveSrc` is the escape hatch for share-page URLs (Screen Studio, Wistia, generic blogs) that publish their direct media URL through `og:video`. Drop the route below into your Next.js app and pair it with `resolveSrc` on the player.
TSsrc/app/api/og-video/route.ts
import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";

const META_RE =
  /<meta\s+(?:[^>]*?\s+)?property=["']([^"']+)["'][^>]*?content=["']([^"']+)["'][^>]*>/gi;
const META_RE_NAME =
  /<meta\s+(?:[^>]*?\s+)?name=["']([^"']+)["'][^>]*?content=["']([^"']+)["'][^>]*>/gi;

function decode(html: string): string {
  return html
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&#x2F;/gi, "/");
}

function parseOg(html: string) {
  const map = new Map<string, string>();
  let match: RegExpExecArray | null;
  for (const re of [META_RE, META_RE_NAME]) {
    re.lastIndex = 0;
    while ((match = re.exec(html)) !== null) {
      map.set(match[1].toLowerCase(), decode(match[2]));
    }
  }
  return {
    src:
      map.get("og:video:secure_url") ??
      map.get("og:video") ??
      map.get("twitter:player:stream") ??
      null,
    poster: map.get("og:image:secure_url") ?? map.get("og:image") ?? null,
  };
}

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get("url");
  if (!url) return NextResponse.json({ error: "Missing url" }, { status: 400 });

  try {
    const res = await fetch(url, {
      redirect: "follow",
      headers: { "user-agent": "Mozilla/5.0 (squad-ui og-video)" },
      cache: "no-store",
    });
    if (!res.ok) {
      return NextResponse.json({ error: `Upstream ${res.status}` }, { status: 502 });
    }
    const payload = parseOg(await res.text());
    if (!payload.src) {
      return NextResponse.json({ error: "No og:video tag" }, { status: 404 });
    }
    return NextResponse.json(payload, {
      headers: { "cache-control": "public, max-age=300, s-maxage=300" },
    });
  } catch (err) {
    return NextResponse.json(
      { error: err instanceof Error ? err.message : "fetch failed" },
      { status: 502 },
    );
  }
}
TSpage.tsx
// Wire the API route into your player so non-direct URLs resolve to MP4.
async function resolveOgVideo(rawSrc: string) {
  const res = await fetch(`/api/og-video?url=${encodeURIComponent(rawSrc)}`);
  if (!res.ok) throw new Error(`Resolver failed (${res.status})`);
  return (await res.json()) as { src: string; poster?: string };
}

<VideoPlayer src={anySharePageUrl} resolveSrc={resolveOgVideo} />
Usage
Import the component and pass any video URL. Native video controls are replaced with the styled bottom bar.
TSImport
import { VideoPlayer } from "@/components/ui/video-player";
TSExample
import { VideoPlayer } from "@/components/ui/video-player";

export function Example() {
  return (
    <VideoPlayer
      src="https://example.com/video.mp4"
      poster="https://example.com/poster.jpg"
    />
  );
}
Composition
Anatomy of the VideoPlayer.
VideoPlayer                       (single component — pass content as props)
├── src=""                         (URL of the video — mp4, webm, or HLS in Safari)
├── poster=""                      (fallback image shown before play and on errors)
├── tracks={[{ src, label, srclang, default, kind }]}  (WebVTT captions)
├── playbackRates={[0.5, 1, 1.5, 2]}                   (speed menu options)
├── aspectRatio="16/9"
└── ...standard <video> attrs (autoPlay, loop, muted, …)

Built-in controls:
- Big center Play/Pause button when paused
- Bottom bar: Play/Pause, mute, volume slider, time, speed menu, captions menu, picture-in-picture, fullscreen
- Click the video surface to toggle play/pause
- Seek by scrubbing the timeline at the top of the controls bar
Default
Drop in a `src` and you get the full controls bar — play/pause, mute, volume, scrub, speed, picture-in-picture, fullscreen. The showcase below ships with a URL input so you can paste a different MP4/WebM and watch it load.
0
1
0:00 / 0:00

Paste a direct MP4 / WebM URL, a share link from a service that publishes og:video (Screen Studio, Wistia, etc.), or a Loom / YouTube / Vimeo embed URL. Press Enter or blur the field to load it.

Loom / YouTube / Vimeo
Paste a Loom `share` or `embed` URL — the player converts it to the embed iframe automatically and lets Loom's player handle controls. YouTube and Vimeo URLs work the same way.

Loom embed / share URLs are auto-converted to the embed iframe so the player just works for Loom-hosted videos. YouTube and Vimeo links are handled the same way.

Screen Studio (og:video resolver)
Pages without an embed iframe (Screen Studio, Wistia, generic blog posts) usually publish the underlying MP4 in their `og:video` meta tag. Pass a `resolveSrc` callback that hits an /api/og-video route to fetch the share page server-side, parse the tag, and return the direct media URL — the native player handles it from there.
0
1
0:00 / 0:00

Screen Studio (and any service that publishes its video URL via og:video) plays natively. The component calls resolveSrc, which hits a Next.js route that fetches the share page server-side and returns the underlying MP4.

API Reference
Props exposed by the VideoPlayer component.
PropTypeDefaultDescription
srcstringVideo URL. Direct MP4 / WebM / HLS plays in the native controls bar. Loom, YouTube, and Vimeo URLs auto-convert to the host's embed iframe. Other share URLs go through `resolveSrc` if provided.
posterstringFallback image shown before playback and on load failures.
tracksVideoPlayerTrack[]WebVTT subtitle / caption tracks. Each `{ src, label, srclang?, default?, kind? }` becomes a `<track>` plus a captions-menu entry.
resolveSrc(rawSrc: string) => Promise<string | { src: string; poster?: string }>Async resolver for share pages that don't expose an embed iframe (Screen Studio, Wistia, etc.). Pair it with a server route that fetches the page and parses `og:video` to return the underlying MP4.
fallbackPosterstringSquad brand badgeLast-resort poster shown when no `poster` is set, no resolver poster came back, and the first-frame capture failed (e.g. cross-origin without CORS).
posterFromFirstFramebooleantrueCapture the first decoded frame and use it as a fallback poster when `poster` is omitted. Silently skipped for cross-origin videos without CORS — pair with `crossOrigin="anonymous"` to enable it on those.
crossOrigin"anonymous" | "use-credentials"Forwarded to the underlying `<video>` element. Set this when you control the CDN's CORS headers and want first-frame poster capture to work on cross-origin sources.
playbackRatesnumber[][0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]Options shown in the speed menu.
aspectRatiostring"16/9"CSS `aspect-ratio` of the player surface.
classNamestringOverride or extend the resolved root classes.
...restVideoHTMLAttributesForwarded to the underlying `<video>` element — `autoPlay`, `loop`, `muted`, `crossOrigin`, etc.