001

An IIIF-Native Player
for Audio, Video & Transcripts

A Svelte 5 component library for bidirectional synchronization between IIIF media playback and transcript navigation. Unstyled primitives for scholarly and archival applications.

Built on IIIF (International Image Interoperability Framework) — the standard used by libraries, museums, and archives worldwide.

IIIF Compliant
TypeScript
Unstyled Primitives
Open Source
002

Live Demonstration

Watch the components in action with a real IIIF video and VTT captions from the IIIF Cookbook. Click transcript segments to seek; play video to auto-scroll the active cue.

Loading transcript...

More demos

Each demo isolates one capability against a real IIIF manifest: four component features, and three real-world manifests.

003

SEO: Structured Data from VTT

Transcript text is invisible to search engines when it only lives inside a VTT file. By parsing VTT at build time with media-captions, you can inject the full transcript into Schema.org/VideoObject structured data — making every word indexable by Google, Bing, and AI systems.

This page practices what it preaches. View source on this page to see the <script type="application/ld+json"> in the <head> — generated at build time from the same IIIF manifest that powers the interactive demo above.

The Pattern: IIIF Manifest → Structured Data

In an Astro page, the frontmatter runs at build time on the server. Fetch the same IIIF manifest the player uses, extract the transcript text, and inject it as Schema.org structured data. The manifest is the single source of truth for both the player and search engines.

---
// Astro frontmatter — runs at build time
import { parseText } from 'media-captions';
import {
  ManifestSchema, getFirstCanvas,
  buildTranscriptAnnotations, getSupplementaryVTTTracks,
} from '@umd-mith/iiif-timed-transcript';

const manifestUrl = 'https://example.org/manifest.json';

// Fetch and validate the IIIF manifest
const manifest = await fetch(manifestUrl).then(r => r.json());
const { data } = ManifestSchema.safeParse(manifest);
const canvas = data ? getFirstCanvas(data) : undefined;

let transcript = '';
if (canvas) {
  // Path A: Transcript embedded as IIIF annotations
  const { annotations } = buildTranscriptAnnotations(canvas);
  if (annotations.length > 0) {
    transcript = annotations.map(a => a.text).join(' ');
  } else {
    // Path B: Manifest references an external VTT file (recipe 0219)
    const tracks = getSupplementaryVTTTracks(canvas);
    if (tracks.length > 0) {
      const vttText = await fetch(tracks[0].src).then(r => r.text());
      const { cues } = await parseText(vttText, { type: 'vtt' });
      transcript = cues.map(cue => cue.text).join(' ');
    }
  }
}

const jsonLd = {
  "@context": "https://schema.org",
  "@type": "VideoObject",
  "name": data?.label?.en?.[0] ?? "Untitled",
  "transcript": transcript,
  // ... thumbnailUrl, uploadDate, contentUrl
};
---

<head>
  <script type="application/ld+json"
    set:html={JSON.stringify(jsonLd)} />
</head>

Path A handles manifests with embedded transcript annotations (like the AVAnnotate demo). Path B handles manifests that reference an external VTT file (IIIF Cookbook recipe 0219), using media-captions as a lightweight VTT parser.

Why media-captions?

media-captions is the caption parsing library from the Vidstack project (3,400+ GitHub stars). MIT licensed, actively maintained, and built specifically to address the gaps in native browser caption support.

W3C WebVTT spec-compliant

Follows the W3C WebVTT specification for parsing and rendering — correct cue settings, region positioning, and collision detection. Not a regex approximation, but a proper spec implementation.

Accessibility-driven

Built to meet FCC Communications and Video Accessibility Act requirements. Correct caption positioning matters for WCAG compliance — native browser ::cue styling is too limited for accessible overlay rendering.

Same library, both sides

Works in Astro frontmatter (Node/build time) and in Svelte components (browser/runtime). One dependency for SEO extraction and interactive playback — no environment-specific forks.

Multi-format, ~5kB

Parses VTT, SRT, and SSA/ASS. TypeScript-native with proper VTTCue types. Tree-shakes to just the parser you use. Zero dependencies.

What Search Engines See

Without structured data, crawlers see a <video> tag and a VTT URL — opaque binary content. With the transcript property, every spoken word becomes indexable text:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "VideoObject",
  "name": "Lunchroom Manners",
  "transcript": "Do you know the lunchroom rules? ..."
}
</script>

The transcript property is defined on Schema.org/AudioObject and Schema.org/VideoObject (inherited from MediaObject). Works for both audio and video content.

004

Getting Started

Install the library and start building synchronized media experiences in minutes. No configuration required.

Installation

npm install @umd-mith/iiif-timed-transcript

Peer dependencies: svelte@^5

Basic Usage

<script>
  import { IIIFPlayer, type Annotation } from '@umd-mith/iiif-timed-transcript';

  const manifestUrl = 'https://example.org/manifest.json';

  // Parsed VTT or IIIF annotations
  const annotations: Annotation[] = [
    { id: 'cue-0', startTime: 0, endTime: 4.5, text: 'Welcome to the presentation.' },
    { id: 'cue-1', startTime: 4.5, endTime: 9.2, text: 'Today we will discuss...' },
    { id: 'cue-2', startTime: 9.2, endTime: 14.0, text: 'Let us begin with the first topic.' }
  ];
</script>

<IIIFPlayer.Root {manifestUrl} canvasIndex={0}>
  <IIIFPlayer.Viewer />

  <IIIFPlayer.Controls>
    <IIIFPlayer.PlayButton />
    <IIIFPlayer.Progress />
    <IIIFPlayer.Skip seconds={-10} />
    <IIIFPlayer.Skip seconds={30} />
    <IIIFPlayer.Speed />
    <IIIFPlayer.Time />
  </IIIFPlayer.Controls>

  <IIIFPlayer.Transcript {annotations}>
    <IIIFPlayer.TranscriptSearch />
    <IIIFPlayer.TranscriptSegments />
  </IIIFPlayer.Transcript>
</IIIFPlayer.Root>

Using with Astro

The compound components work seamlessly in Astro islands with proper hydration directives:

<IIIFPlayer.Root client:load {manifestUrl} canvasIndex={0}>
  <IIIFPlayer.Viewer />

  <IIIFPlayer.Controls>
    <IIIFPlayer.PlayButton />
    <IIIFPlayer.Progress />
    <IIIFPlayer.Skip seconds={-10} />
    <IIIFPlayer.Skip seconds={30} />
    <IIIFPlayer.Speed />
    <IIIFPlayer.Time />
  </IIIFPlayer.Controls>

  <IIIFPlayer.Transcript {annotations}>
    <IIIFPlayer.TranscriptSearch />
    <IIIFPlayer.TranscriptSegments />
  </IIIFPlayer.Transcript>
</IIIFPlayer.Root>

Hydration tip: Use client:load on the wrapper component (here, Root) for immediate media initialization. Child components inside the hydration boundary do not need their own client: directives — Astro hydrates the entire island.

005

Component API

Composable building blocks for creating custom IIIF media player interfaces. All components are unstyled primitives with data-* attributes for styling.

IIIFPlayer.Root

Top-level context provider that manages player state and coordinates all child components. Provides PlayerContext to children.

Props:

  • manifestUrl: string - IIIF Presentation 3.0 manifest URL
  • canvasIndex?: number - Canvas to display (default: 0)
  • initialTime?: number - Start playback at specific time
  • autoplay?: boolean - Auto-play when ready (default: false)
  • class?: string - CSS class for the root wrapper

IIIFPlayer.Viewer

Renders the IIIF media resource (video/audio) from the current canvas. Receives PlayerContext from Root.

Props:

  • controls?: boolean - Show native browser controls (default: false)
  • crossOrigin?: 'anonymous' | 'use-credentials' - CORS setting for media
  • class?: string - CSS class for the media element

IIIFPlayer.Controls

Container for player control components. Use as layout wrapper for PlayButton, Progress, Skip, etc.

Child Components:

  • PlayButton - Toggle play/pause
  • Progress - Seekable progress bar
  • Skip - Skip forward/back (seconds prop)
  • Speed - Playback speed selector
  • Time - Display current time and duration

IIIFPlayer.Transcript

Bidirectional synchronized transcript panel. Click segment to seek, video auto-scrolls active segment. Compose with compound children for full control over layout.

Props:

  • annotations: Annotation[] - Transcript segments with timing
  • ariaLabel?: string - Region aria-label (default: 'Media transcript')
  • onActiveAnnotationChange?: (annotation | null) => void - Active segment callback
  • onSegmentClick?: (annotation, event) => void - Click handler with preventDefault
  • class?: string - CSS class for the transcript region

Compound Children:

  • TranscriptSearch - Search UI with match navigation
  • TranscriptSegments - Segment list with active/highlight states

Custom Segment Rendering

Use the segment snippet on TranscriptSegments for full control over how each segment renders. The segmentAttrs spread provides all a11y attributes, data attributes, tabindex, and click handling automatically.

<IIIFPlayer.Transcript {annotations}>
  <IIIFPlayer.TranscriptSearch />
  <IIIFPlayer.TranscriptSegments>
    {#snippet segment({ annotation, isActive, segmentAttrs })}
      <button {...segmentAttrs}>
        {#if annotation.metadata?.speaker}
          <strong>{annotation.metadata.speaker}:</strong>
        {/if}
        <span class:active={isActive}>{annotation.text}</span>
      </button>
    {/snippet}
  </IIIFPlayer.TranscriptSegments>
</IIIFPlayer.Transcript>

The segmentAttrs object includes data-annotation-id, data-state, aria-current, role, tabindex, and onclick — so you never need to wire those up manually. See the AVAnnotate demo for a full example with speaker tags and filtering.

Styling Guide

All components ship unstyled with data-* attributes. Here's a minimal example to get started.

Transcript Segment Highlighting

/* Target transcript segments via data attributes */
[data-annotation-id] {
  padding: 0.75rem;
  margin-bottom: 0.5rem;
  border-radius: 0.25rem;
  transition: background-color 0.2s;
  text-align: left;
}

[data-annotation-id]:hover {
  background-color: #f3f4f6;
}

/* Active segment highlighting */
[data-annotation-id][data-state='active'] {
  background-color: #bae6fd;
  border-left: 4px solid #0ea5e9;
}

/* Search match highlighting */
[data-annotation-id][data-highlighted='true'] {
  background-color: #fef9c3;
}

/* Current search match (stronger) */
[data-annotation-id][data-current-match='true'] {
  background-color: #fde68a;
  border-left: 4px solid #eab308;
}

[data-annotation-id] .timestamp {
  font-size: 0.75rem;
  color: #6b7280;
  font-weight: 600;
}

[data-annotation-id] .text {
  color: #1f2937;
  line-height: 1.5;
}

Audio Player Controls

/* Style control bar via data attributes */
[data-audio-controls] {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 1rem;
  background: white;
  border-radius: 0.5rem;
}

[data-audio-button] {
  padding: 0.5rem;
  background: #f3f4f6;
  border-radius: 0.25rem;
  transition: background-color 0.2s;
}

[data-audio-button]:hover {
  background: #e5e7eb;
}

/* Progress bar */
input[type="range"][data-audio-progress] {
  width: 100%;
  height: 0.5rem;
  cursor: pointer;
  accent-color: #0ea5e9;
}

/* Speed selector */
select[data-audio-control="speed"] {
  padding: 0.25rem 0.5rem;
  border-radius: 0.25rem;
}

/* Time display */
[data-audio-control="time"] {
  font-size: 0.875rem;
  font-variant-numeric: tabular-nums;
}