@umd-mith/iiif-timed-transcript
v0.18.0
Published
Svelte 5 component library for synchronized IIIF media playback with transcript navigation
Maintainers
Readme
@umd-mith/iiif-timed-transcript
Svelte 5 components that synchronize media playback with timed transcripts from IIIF manifests. Designed for digital humanities projects, oral history archives, and anywhere time-based annotations meet audio/video.
The compound component API lets you compose custom player layouts from small, focused pieces — controls, transcript panel, chapter navigation, canvas switching — while a bidirectional sync engine keeps the transcript scroll position and media playback in sync.
What this library provides
- Bidirectional sync — clicking a transcript segment seeks the media; media playback scrolls the transcript (XState 5 state machine)
- Full-text search across transcript segments with match-by-match navigation
- Multi-canvas support — switch between canvases in a manifest, with chapter markers per canvas
- HLS adaptive streaming — auto-detects HLS URLs and uses
hls.jswhen available (Safari uses native HLS) - Custom segment rendering via Svelte 5 snippets and a
segmentAttrsspread for accessibility - Unstyled by default — all components expose
data-*attributes for CSS targeting; bring your own styles
Install
npm install @umd-mith/iiif-timed-transcriptOptional peers for adaptive streaming — install only what your manifests need:
npm install hls.js # HLS (.m3u8) sources
npm install dashjs # MPEG-DASH (.mpd) sourcesPeer Dependencies
svelte ^5.0.0hls.js(optional — needed only for HLS streams on non-Safari browsers)
Local Development
git clone https://github.com/umd-mith/iiif-timed-transcript.git
cd iiif-timed-transcript
pnpm install
pnpm run buildQuick Start
<script>
import { IIIFPlayer } from "@umd-mith/iiif-timed-transcript";
const manifestUrl = "https://example.org/manifest.json";
const annotations = [
{ id: "a1", startTime: 0, endTime: 5, text: "First segment" },
{ id: "a2", startTime: 5, endTime: 10, text: "Second segment" },
];
</script>
<IIIFPlayer.Root {manifestUrl} canvasIndex={0}>
<IIIFPlayer.Viewer />
<IIIFPlayer.Controls>
<IIIFPlayer.PlayButton />
<IIIFPlayer.Progress />
<IIIFPlayer.Skip seconds={10} />
<IIIFPlayer.Speed />
<IIIFPlayer.Time />
</IIIFPlayer.Controls>
<IIIFPlayer.Transcript {annotations}>
<IIIFPlayer.TranscriptSearch />
<IIIFPlayer.TranscriptSegments />
</IIIFPlayer.Transcript>
</IIIFPlayer.Root>Component API Reference
Core Components
IIIFPlayer.Root
Top-level context provider. Fetches the IIIF manifest, parses canvases, and coordinates all child components via Svelte context.
| Prop | Type | Default | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| manifestUrl | string | required | IIIF Presentation 3.0 manifest URL |
| canvasIndex | number | 0 | Canvas to display |
| annotations | Annotation[] \| "auto" | [] | Transcript annotations passed through to context. "auto" builds them from the manifest: embedded TextualBody annotations first, else the canvas's external WebVTT supplementing track (fetched on demand). See precedence |
| initialTime | number | — | Start playback at this time (seconds) |
| autoplay | boolean | false | Auto-play media on load |
| hlsConstructor | HlsConstructor | — | Custom hls.js constructor (bypasses dynamic import) |
| dashConstructor | DashConstructor | — | Custom dash.js MediaPlayer factory (bypasses dynamic import) |
| onCanvasChange | (index: number, canvas: CanvasInfo) => void | — | Calls when canvas switches |
| onPlayerInit | (player: PlayerRef) => void | — | Runs once after manifest loads. See Accessing player state outside Root |
| onError | (error: Error, info: { fatal: boolean; source: "manifest" \| "canvas" \| "media" \| "playback" \| "transcript" \| "auth" }) => void | — | Called for every reported error. fatal: true means the player will not become usable; fatal: false (a rejected play(), a transcript that failed to load) needs no action |
| preprocessManifest | (raw: unknown) => unknown | — | Runs on the parsed manifest JSON before validation; its output is also what chapters are read from (raw). When set, the module-level manifest cache is bypassed |
| class | string | "" | CSS class for root container |
| locale | string | — | BCP 47 locale for UI copy. See Interface Language (i18n) |
annotations="auto" precedence. Tier 1 is the manifest's embedded transcript: TextualBody annotations with a temporal target and a motivation of supplementing, commenting, or tagging. If tier 1 yields any annotation with text, that is the transcript. Tier 2 — the canvas's external WebVTT supplementing track — runs only when tier 1 yields none, so the VTT is not even fetched when embedded annotations exist. The consequence: a manifest that carries both editorial notes (as commenting/tagging annotations) and a VTT transcript will show the notes, not the transcript. If that is your manifest, pass annotations explicitly instead of "auto".
Children snippet:
Root passes { player } to its children snippet with state, actions, annotations, chapters, activeChapterId, and transcriptStatus ("idle" | "loading" | "ready" | "error" — the lifecycle of annotations="auto"'s VTT fetch; "idle" when you pass annotations yourself):
<IIIFPlayer.Root {manifestUrl}>
{#snippet children({ player })}
<p>Time: {player.state.currentTime}</p>
<p>Chapter: {player.activeChapterId}</p>
<button onclick={() => player.actions.seekTo(0)}>Restart</button>
{/snippet}
</IIIFPlayer.Root>IIIFPlayer.Viewer
Renders the media element (<audio> or <video>) for the current canvas. Auto-wires HLS when the media strategy requires it.
| Prop | Type | Default | Description |
| ------------- | ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| controls | boolean | false | Show native media controls |
| crossOrigin | 'anonymous' \| 'use-credentials' | — | CORS setting |
| preload | 'auto' \| 'metadata' \| 'none' | 'auto' | Preload strategy |
| poster | string | — | Poster image for video (shown before playback). Overrides the IIIF-derived poster; pass "" to suppress it |
| tracks | TrackDefinition[] | [] | Caption/subtitle tracks (overrides auto-discovered tracks from manifest) |
| class | string | "" | CSS class |
When poster is omitted, the player automatically derives one from the current canvas's IIIF placeholderCanvas, falling back to accompanyingCanvas. Poster images apply to video canvases only.
Captions discovered from the manifest are attached as <track> elements exactly as before. When the transcript panel is populated — from annotations="auto", from a Transcript annotations prop, or from Root's — Viewer switches every attached track to mode = "hidden" once per canvas, so the same text is not shown twice. The tracks stay attached, so a viewer can turn them back on — and keep them on — two ways. Passing controls shows the browser's native video controls, whose caption menu selects the track; Viewer defaults to controls={false}, so that menu is absent by default. Alternatively, render the library's IIIFPlayer.Captions button, which toggles captions with no native controls needed. An explicit tracks prop is left alone.
Because that write happens once per canvas, it is not undone: if your app shows and hides the transcript panel (a toggle, a tab, a responsive breakpoint), the native captions are not turned back on when the panel goes away. If you render your own controls without a caption toggle and need the captions to stay on, pass tracks explicitly — the policy never touches those — and set the track modes yourself.
If the VTT lives on a different origin from the page, set crossOrigin="anonymous" on Viewer (and serve the VTT with CORS headers): browsers refuse a cross-origin <track> without it, so the native caption toggle silently does nothing. Note also that on a video canvas under annotations="auto" tier 2 the VTT is requested twice — once by the library to build the transcript, once by the browser for the <track> — normally served the second time from the HTTP cache. Audio canvases render an <audio> element with no <track>, so there is only the one request.
IIIFPlayer.Controls
Pass-through container for control components. Use for layout.
| Prop | Type | Default | Description |
| ------- | -------- | ------- | ----------- |
| class | string | "" | CSS class |
Data attribute: data-audio-controls
Control Components
IIIFPlayer.PlayButton
Toggle play/pause. Shows "Play", "Pause", or "Loading…" based on player state.
- Data attribute:
data-audio-button="play-pause" - Prop:
class?: string
IIIFPlayer.Progress
Seekable range input (<input type="range">).
- Data attribute:
data-audio-progress - Prop:
class?: string
IIIFPlayer.Skip
Skip forward or backward.
| Prop | Type | Description |
| --------- | -------- | ------------------------------------- |
| seconds | number | Seconds to skip (negative for rewind) |
| class | string | CSS class |
Data attribute: data-audio-button="skip"
IIIFPlayer.Speed
Playback rate selector (<select>).
| Prop | Type | Default | Description |
| ------- | ---------- | ------------------------------ | --------------- |
| rates | number[] | [0.5, 0.75, 1, 1.25, 1.5, 2] | Available rates |
| class | string | "" | CSS class |
Data attribute: data-audio-control="speed"
IIIFPlayer.Time
Displays current time and duration as M:SS / M:SS (minutes are not zero-padded below one hour; H:MM:SS for durations >= 1 hour).
- Data attribute:
data-audio-control="time" - Prop:
class?: string
IIIFPlayer.Captions
Caption toggle button (CC). Renders only for video canvases that carry caption tracks — hidden for audio and for video with no <track>. Reflects and drives the caption state machine, staying truthful even when captions are toggled via the browser's native CC menu.
- Data attribute:
data-audio-button="captions" - Accessibility:
aria-pressedreflects whether captions are showing;aria-labelis translatable (captions.toggleLabel). - Prop:
class?: string
Transcript Components
IIIFPlayer.Transcript
Synchronized transcript panel. Manages bidirectional scroll↔media sync via an XState-based SyncController. Provides TranscriptContext to compound children.
| Prop | Type | Default | Description |
| -------------------------- | ------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| annotations | Annotation[] | [] | Transcript segments (falls back to Root context annotations) |
| syncDebounceMs | number | 150 | Scroll debounce (ms) |
| syncSettleMs | number | 100 | Settle delay after scroll (ms) |
| syncPriorityLockDuration | number | 1000 | Priority lock duration (ms) |
| searchSeekBehavior | "change" \| "activate" | "change" | "change" preserves legacy behavior — every selected search match seeks immediately. "activate" seeks only on explicit activation (Enter or "Go to match"); typing and previous/next only update highlights. Recommended: "activate". |
| scrollToSeek | boolean | true | When false, transcript scrolling never seeks; a deliberate scroll instead enters Browsing (reading mode) so the next playback update can't pull the panel back. Independent of readingMode. Changeable at runtime. Recommended: false. |
| readingMode | boolean (bindable) | false | false is Following (playback may scroll the panel, and may seek if scrollToSeek is on); true is Browsing (the panel is detached — playback still updates the active-passage highlight, but never scrolls or seeks). Turning it back off scrolls the passage at the current time into view without seeking. Panel-local. |
| ariaLabel | string | "Media transcript" | Region label |
| announceActiveSegment | boolean | true | Screen reader announcements for active segment |
| onActiveAnnotationChange | (annotation: Annotation \| null) => void | — | Calls when active segment changes |
| onSegmentClick | (annotation: Annotation, event: { preventDefault: () => void }) => void | — | Intercept clicks; call preventDefault() synchronously to suppress default seek |
| empty | Snippet | — | Custom empty state |
| loading | Snippet | — | Custom loading state (replaces the built-in "Loading transcript…") |
| class | string | "" | CSS class |
Children: Use TranscriptSearch and TranscriptSegments as compound children. Without children, renders the empty state.
While Root is fetching a VTT transcript (annotations="auto", transcriptStatus === "loading") the panel shows "Loading transcript…" with aria-busy="true" — this replaces the empty state and the empty snippet for as long as the status is "loading". Pass a loading snippet to render your own affordance (translated copy, a skeleton) in its place.
There is no matching error snippet: when the fetch fails (transcriptStatus === "error") the panel falls back to the ordinary empty state. If you want a distinct failure affordance, read transcriptStatus from the Root children snippet or handle onError and render it yourself.
Recommended configuration: searchSeekBehavior="activate" and scrollToSeek={false}. Playback follows the transcript until the reader searches, scrolls, or explicitly enables reading mode — search and browsing no longer fight playback for control of the panel. With no new props, Transcript keeps today's exact behavior (search-driven and scroll-driven seeking).
Whenever children are present, Transcript renders one "Follow along" switch (role="switch", aria-checked, data-following) — turning it off enters Browsing — and, only while Browsing, a "Jump to current" button that returns to Following by scrolling the passage at the media's current time into view (never seeking). Both are bare: no appearance, styled by the host (the default <iiif-transcript-player> element ships a styled pair — see "Use without Svelte" below). This single control replaces the former auto-scroll-pause button; there is no separate "pause auto-scroll but keep scroll-to-seek" state.
IIIFPlayer.TranscriptSearch
Search input for filtering transcript segments. Dual-mode: reads from TranscriptContext inside Transcript, or accepts props directly when used standalone.
| Prop | Type | Default | Description |
| ----------------- | ------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| annotations | Annotation[] | from context | Annotations to search |
| placeholder | string | "Search transcript…" | Input placeholder |
| debounceMs | number | 150 | Input debounce (ms) |
| onmatchchange | (matches: Annotation[], index: number) => void | from context | Debounced match-list/selection change callback |
| onmatchactivate | (annotation: Annotation, index: number) => void | from context | Explicit activation (Enter, or "Go to match" — shown when Transcript's searchSeekBehavior is "activate" or while Browsing). Seeks once, regardless of searchSeekBehavior. |
| onmatchnavigate | (annotation: Annotation, index: number) => void | from context | Fired only on previous/next navigation (never typing or activation). The default scrolls the newly-selected match into view through the context; never seeks. |
| onqueryinput | (value: string) => void | from context | Synchronous input-intent callback, fired before the debounce — the default enters Browsing on a nonempty query in "activate" mode. |
| class | string | "" | CSS class |
IIIFPlayer.TranscriptSegments
Renders transcript segments with active/search highlighting and roving tabindex keyboard navigation. Dual-mode: reads from TranscriptContext inside Transcript, or accepts props directly.
| Prop | Type | Default | Description |
| ------------------------- | ----------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| annotations | Annotation[] | from context | Annotations to display |
| activeAnnotationId | string \| null | from context | Currently active annotation |
| highlightedIds | Set<string> | from context | Search-highlighted annotation IDs |
| currentMatchId | string \| null | from context | Current search match (stronger highlight) |
| highlightedAnnotationId | string \| null | null | Deep-link highlight (merged with search highlights) |
| onclick | (annotation: Annotation) => void | from context | Segment click handler |
| onkeydown | (event: KeyboardEvent, context: { annotation, index, annotations }) => void | — | Keyboard handler on container; call preventDefault() to suppress built-in Arrow/Home/End navigation |
| segment | Snippet<[SegmentSnippetProps]> | — | Full custom segment rendering (see Custom segment snippets) |
| text | Snippet<[{ annotation: Annotation }]> | — | Custom text rendering only |
| class | string | "" | CSS class |
Data attributes on each segment:
| Attribute | Values | Description |
| -------------------- | -------------------------- | -------------------- |
| data-annotation-id | segment ID | Unique identifier |
| data-state | "active" | "inactive" | Playback state |
| data-highlighted | "true" or absent | Matches search query |
| data-current-match | "true" or absent | Focused search match |
Navigation Components
IIIFPlayer.Chapters
Displays chapter markers from the IIIF manifest's Range structures. Clicking a chapter seeks to its start time.
| Prop | Type | Default | Description |
| ----------------------- | ------------------------------------------------------------------------- | ------- | --------------------------------- |
| onActiveChapterChange | (chapter: Chapter \| null) => void | — | Calls when active chapter changes |
| chapter | Snippet<[{ chapter: Chapter, isActive: boolean, onClick: () => void }]> | — | Custom chapter rendering |
| empty | Snippet | — | Custom empty state |
| class | string | "" | CSS class |
Data attributes (default rendering):
data-chapter-id— chapter identifierdata-state="active|inactive"— current chapter state
IIIFPlayer.CanvasNav
Multi-canvas navigation. Hides itself when the manifest contains a single canvas.
| Prop | Type | Default | Description |
| ---------------- | --------------------------------------------------------------------------- | ------- | -------------------------------- |
| onCanvasChange | (canvas: CanvasInfo) => void | — | Calls when user selects a canvas |
| canvas | Snippet<[{ canvas: CanvasInfo, isActive: boolean, onClick: () => void }]> | — | Custom canvas rendering |
| empty | Snippet | — | Custom empty state |
| class | string | "" | CSS class |
Data attributes (default rendering):
data-canvas-index— canvas positiondata-state="active|inactive"— current canvas state
Styling
All components ship unstyled with semantic HTML and data-* attributes for CSS hooks.
Styling Targets
| Area | Selector | Notes |
| ------------------- | ---------------------------------------------------------------------- | -------------------------------------------- |
| Transcript segments | [data-annotation-id] | Use [data-state="active"] for highlighting |
| Player controls | [data-audio-button], [data-audio-progress], [data-audio-control] | |
| Search input | input[type="search"] | Inside .transcript-search-sticky |
| Chapter markers | [data-chapter-id] | Use [data-state="active"] |
| Canvas nav | [data-canvas-index] | Use [data-state="active"] |
| Scroll container | .transcript-panel | The sync engine's scroll target |
| Segments container | .segments-container | Wraps all segments |
Example: Active Segment Highlighting
[data-annotation-id] {
padding: 0.75rem;
margin-bottom: 0.5rem;
border: none;
background: white;
text-align: left;
cursor: pointer;
transition: background-color 0.2s;
}
[data-annotation-id]:hover {
background-color: #f3f4f6;
}
[data-annotation-id][data-state="active"] {
background-color: #bae6fd;
border-left: 4px solid #0ea5e9;
}
[data-annotation-id][data-highlighted="true"] {
background-color: #fef9c3;
}
[data-annotation-id][data-current-match="true"] {
background-color: #fde68a;
border-left: 4px solid #f59e0b;
}Annotations
The Annotation Type
interface Annotation {
id: string;
startTime: number; // seconds
endTime: number; // seconds
text: string;
metadata?: Record<string, unknown>;
}Parsing Annotations
The companion package @umd-mith/iiif-media-parsers parses IIIF annotation targets, media fragments, ranges, and VTT speaker segments. This library re-exports its key functions (parseMediaFragment, parseAnnotationTarget, parseRanges, parseVTTSpeakers) and types (Chapter, SpeakerSegment, TemporalFragment, SpatialFragment, ParsedAnnotationTarget) so consumers need only one import source.
You usually do not need to parse VTT yourself: <IIIFPlayer.Root annotations="auto"> builds transcript annotations from the manifest (embedded TextualBody first, else the canvas's external WebVTT supplementing track). The building blocks are exported for custom pipelines: selectTranscriptTrack, loadVTTTranscript, buildAnnotationsFromVTTCues, vttCueToPlainText (cue text is reduced to plain text — tags stripped, entities decoded — via media-captions' tokenizeVTTCue). For the "auto" path, see the live demo (source); for a hand-rolled example, see docs/src/components/IIIFTranscriptDemo.svelte.
getPrimaryResource now resolves Choice bodies (valid Presentation 3, and how Avalon wraps HLS renditions): a Choice resolves to its first Sound/Video member; a body array is walked in order, so [Image, Choice{Video}] → Image but [Choice{Video}, Image] → Video. A missing hls.js/dashjs for a stream that needs it is now a surfaced player error (state.error, onError source: "media") in addition to the console warning.
Using annotation.metadata
The metadata field (Record<string, unknown>) holds consumer-specific data. Library components ignore metadata; access it in custom segment snippets.
Speaker labels:
<IIIFPlayer.TranscriptSegments {annotations}>
{#snippet segment({ annotation, segmentAttrs })}
<div {...segmentAttrs}>
{#if annotation.metadata?.speaker}
<strong>{annotation.metadata.speaker}:</strong>
{/if}
<span>{annotation.text}</span>
</div>
{/snippet}
</IIIFPlayer.TranscriptSegments>Paragraph Merging
import { mergeIntoParagraphs } from "@umd-mith/iiif-timed-transcript";
const speakers = new Map(
annotations
.filter((a) => a.metadata?.speaker)
.map((a) => [a.id, a.metadata!.speaker as string]),
);
const paragraphs = mergeIntoParagraphs(annotations, { speakers });Interface Language (i18n)
All of the player's interface text (button labels, transcript messages) comes from a translation registry. English is built in. A host adds a language with registerTranslation, and rendered strings update in place:
import {
registerTranslation,
setLocale,
} from "@umd-mith/iiif-timed-transcript";
registerTranslation("fr", {
"player.playButton.play": "Lecture",
"transcript.unavailable": "Aucune transcription disponible.",
});
setLocale("fr"); // or <IIIFPlayer.Root locale="fr">A key a locale does not cover falls back to English, key by key. The TermKey type lists every key. Script-tag hosts call window.IIIFTranscriptPlayer.registerTranslation; ESM element hosts import it from …/element.
Who sets the language differs by consumer, on purpose. The <iiif-transcript-player> element reads the page language itself: the nearest lang attribute wins, then <html lang>, then English — and it follows later lang changes automatically. Svelte-API hosts set the language explicitly, with the locale prop on Root or a setLocale call. A Svelte application usually runs its own i18n and should stay in charge of language selection; the element serves pages that have no such machinery.
One shared language per page. The locale is a single module-level value. Every player on a page shows the same language, and the last write wins. Two players with different languages on one page is not supported.
Advanced Patterns
Custom Segment Snippets
The segment snippet on TranscriptSegments gives full control over rendering. It receives a segmentAttrs object that bundles data-* attributes, aria-current, role, tabindex, and onclick. Spread it onto your root element for correct behavior:
<IIIFPlayer.TranscriptSegments>
{#snippet segment({
annotation,
isActive,
isHighlighted,
isCurrentMatch,
segmentAttrs,
})}
<div {...segmentAttrs} class="my-segment" class:active={isActive}>
<span class="timestamp">{annotation.startTime}</span>
<p>{annotation.text}</p>
{#if annotation.metadata?.confidence != null && annotation.metadata.confidence < 0.5}
<span class="badge">Low confidence</span>
{/if}
</div>
{/snippet}
</IIIFPlayer.TranscriptSegments>The text snippet customizes text rendering alone, keeping the default timestamps and wrapper element:
<IIIFPlayer.TranscriptSegments>
{#snippet text({ annotation })}
<p>{@html sanitize(annotation.text)}</p>
{/snippet}
</IIIFPlayer.TranscriptSegments>Caution: When using
{@html}, sanitize annotation text to prevent XSS. Default rendering escapes text automatically.
Focus management: roving tabindex
segmentAttrs includes a tabindex value (0 or -1) that implements roving tabindex for keyboard navigation. The keyboard-focused segment receives tabindex="0" while all others get tabindex="-1", so Tab moves focus directly to that segment.
If you wrap segments in a container with role="listbox", do not add aria-activedescendant on the container — it conflicts with roving tabindex. These are two mutually exclusive focus management strategies defined by WAI-ARIA:
| Pattern | How focus moves | Who manages it |
| ------------------------------- | --------------------------------------------- | ------------------------- |
| Roving tabindex (used here) | Focus moves to the element via tabindex="0" | segmentAttrs handles it |
| aria-activedescendant | Container keeps focus, points to active item | You'd manage it yourself |
Mixing both sends conflicting signals to assistive technology. Since segmentAttrs already handles roving tabindex, just spread it and let it work.
Accessing Player State Outside Root
Use onPlayerInit to get a reactive PlayerRef in sibling or parent components:
<script lang="ts">
import {
IIIFPlayer,
type PlayerRef,
} from "@umd-mith/iiif-timed-transcript";
let player = $state<PlayerRef | null>(null);
const isPlaying = $derived(player?.state.isPlaying ?? false);
const isAudio = $derived(player?.mediaType === "audio");
</script>
<IIIFPlayer.Root
{manifestUrl}
onPlayerInit={(p) => {
player = p;
}}
>
<IIIFPlayer.Viewer />
<IIIFPlayer.Controls>
<IIIFPlayer.PlayButton />
<IIIFPlayer.Progress />
</IIIFPlayer.Controls>
</IIIFPlayer.Root>
<!-- Sibling components outside Root -->
{#if player}
<p>{player.canvases.length} canvases, currently on #{player.canvasIndex}</p>
{/if}Notes:
- Runs once after manifest loads and first canvas parses; loading failure suppresses the callback
- Returns
state.isReady === falseat init time; use$derivedto react when the media becomes ready - Stays reactive after init because the ref builds on
$stateinternally - Serves components outside Root's subtree; components inside should use
getPlayerContext()ortryGetPlayerContext()instead
Building Dual-Mode Components
TranscriptSearch and TranscriptSegments detect whether they're inside an IIIFPlayer.Root and adapt automatically. Build your own dual-mode components the same way with tryGetPlayerContext():
<script lang="ts">
import { tryGetPlayerContext } from "@umd-mith/iiif-timed-transcript";
let { isPlaying: isPlayingProp = false }: { isPlaying?: boolean } = $props();
const player = tryGetPlayerContext(); // PlayerContext | null
const isPlaying = $derived(player ? player.state.isPlaying : isPlayingProp);
</script>Call tryGetPlayerContext() during component initialization (top-level <script>), never inside event handlers, $effect, or async callbacks. It returns PlayerContext | null — null when the component renders outside a Root.
TypeScript Types
Exported Types
// Player
interface PlayerState {
isPlaying: boolean;
isBuffering: boolean;
currentTime: number;
duration: number;
playbackRate: number;
isReady: boolean;
error: Error | null;
hasEnded: boolean; // true once the media fires `ended`; cleared on replay/canvas switch
isSeeking: boolean; // true between native `seeking` and `seeked`
}
interface PlayerActions {
play: () => Promise<void>;
pause: () => void;
seekTo: (time: number) => void;
setPlaybackRate: (rate: number) => void;
retry: () => Promise<void>;
seekToChapter: (chapter: Chapter) => void;
switchCanvas: (index: number) => void;
}
interface PlayerRef {
readonly state: PlayerState;
readonly actions: PlayerActions;
readonly annotations: Annotation[];
readonly chapters: Chapter[];
readonly activeChapterId: string | null;
readonly canvasIndex: number;
readonly canvasCount: number;
readonly canvases: CanvasInfo[];
readonly mediaType: "audio" | "video";
readonly transcriptStatus: TranscriptStatus;
}
interface PlayerContext {
state: PlayerState;
mediaElement: HTMLMediaElement | null;
mediaUrl: string;
mediaType: "audio" | "video";
posterUrl: string | undefined;
readonly mediaStrategy: MediaStrategy;
readonly hlsAdapter: HlsAdapter | null;
readonly dashAdapter: DashAdapter | null;
readonly annotations: Annotation[];
readonly chapters: Chapter[];
readonly activeChapterId: string | null;
readonly tracks: TrackDefinition[];
readonly canvasIndex: number;
readonly canvasCount: number;
readonly canvases: CanvasInfo[];
readonly transcriptStatus: TranscriptStatus;
// Internal wiring — present on the exported type but not on PlayerRef:
transcriptPopulated: boolean;
readonly captionsState: CaptionsVerdict;
toggleCaptions: () => void;
reportNativeCaptionChange: (mode: "showing" | "hidden") => void;
actions: PlayerActions;
}
type TranscriptStatus = "idle" | "loading" | "ready" | "error";
type PlayerErrorSource =
| "manifest"
| "canvas"
| "media"
| "playback"
| "transcript"
| "auth";
interface PlayerErrorInfo {
fatal: boolean;
source: PlayerErrorSource;
}
interface CanvasInfo {
index: number;
id: string;
label: string;
duration?: number;
mediaType: "audio" | "video";
}
type MediaStrategy = "native" | "hls-js" | "dash-js";
// Transcript
interface TranscriptContext {
state: TranscriptState;
actions: TranscriptActions;
}
// Segments
interface SegmentSnippetProps {
annotation: Annotation;
isActive: boolean;
isHighlighted: boolean;
isCurrentMatch: boolean;
index: number;
segmentAttrs: SegmentAttrs;
}The package entry point exports all types.
Use without Svelte
The package also ships a custom element, <iiif-transcript-player>, for pages with no Svelte (or any JS) build step — a plain HTML page, a CMS theme, a Rails view.
What fills the transcript panel: embedded TextualBody supplementing annotations on the canvas first; otherwise an external WebVTT supplementing track (fetched on demand). A canvas with neither — for example one whose only supplement is a PDF — renders playback (and canvas navigation for multi-canvas manifests) and no transcript panel. A scripted page can pass its own annotations through the annotations property.
Script tag (IIFE)
Load the built IIFE (dist/element/iiif-transcript-player.iife.js) from a CDN at a pinned version, or vendor it into your own static assets.
<iiif-transcript-player
manifest-url="https://iiif.io/api/cookbook/recipe/0219-using-caption-file/manifest.json"
style="min-height: 24rem"
></iiif-transcript-player>
<script src="/assets/iiif-transcript-player.iife.js"></script>The IIFE bundles Svelte and hls.js (HLS plays without any other script). It does not support DASH (dashjs is not bundled; a DASH manifest surfaces a player error). Authenticated/restricted media is not supported. It measures about 744 kB minified (about 232 kB gzipped), against a build-enforced budget of 1 MB.
TypeScript hosts: the IIFE's declaration file (iiif-transcript-player.iife.d.ts, declaring window.IIIFTranscriptPlayer) is not resolvable through the package's exports map — only the ESM entry (./element, below) is. Reference the shipped file directly instead: add /// <reference path="node_modules/@umd-mith/iiif-timed-transcript/dist/element/iiif-transcript-player.iife.d.ts" /> to a .ts file that uses the global, or add that concrete path to tsconfig.json's include.
Module (ESM, with a bundler)
import { register } from "@umd-mith/iiif-timed-transcript/element";
register(); // defines <iiif-transcript-player>; no side effects on importUnlike the IIFE, this entry keeps svelte external, so the svelte peer dependency must be installed in your app — a bundler resolves it from your node_modules like any other import. hls.js / dashjs resolve the same way as optional peers, exactly as for the Svelte components. The ESM build is about 118 kB: it externalizes only dependencies and peerDependencies, so the library's own src/lib is inlined into it. An app that imports both @umd-mith/iiif-timed-transcript and @umd-mith/iiif-timed-transcript/element therefore ships two copies of the player code, with two separate manifestCache instances (a size and cache-duplication cost, not a correctness one). If you already have a Svelte build, use the components directly.
Attributes and properties
| Attribute | Property | Type | Default | Notes |
| ---------------------- | -------------------- | --------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| manifest-url | manifestUrl | string | — | Required. Set once, before the element is connected. To load another manifest, replace the element. A later write does not reload, but it is not inert either: it reaches the player and changes what the next internal retry fetches and evicts from the manifest cache. Connecting without it is a non-fatal host iiif-player-error. Clearing it (removing the attribute, or setting it to "") unmounts the player and resets playerRef to null. |
| canvas-index | canvasIndex | number | 0 | Reactive and reflected: every canvas switch — yours or the player's own navigation — writes the live index back to the attribute, and both channels keep working afterwards. Validated: a value that is not a non-negative integer ("abc") falls back to 0 and the attribute is repaired to 0, and an index past the last canvas is ignored; either way you get a non-fatal host iiif-player-error, once per bad value per connection. Removing the attribute is not an error — it means "back to the default", canvas 0, silently. |
| initial-time | initialTime | number | — | Seconds. Applies to the first canvas only, and only on the element's first connection (see the reconnection note under Events). Validated: a value that is not a non-negative finite number is ignored with a non-fatal host iiif-player-error, and does not consume the once-per-element application. |
| autoplay | autoplay | boolean | false | Browsers may block it; a blocked autoplay is a non-fatal iiif-player-error (source: "playback"). |
| label | label | string | localized generic name | Host-settable accessible name for the element's top-level region (role="region") — set it when a page has more than one player so assistive technology can tell them apart. |
| crossorigin | crossorigin | string | — | Forwarded to the underlying <audio>/<video> element's crossorigin attribute. |
| search-seek-behavior | searchSeekBehavior | string | "change" | "change" or "activate" — see Transcript's prop above. Any other value is a non-fatal host iiif-player-error, once per bad value per connection, and falls back to "change". Recommended: "activate". |
| scroll-to-seek | scrollToSeek | boolean | true | Value-based, not presence-based: "false" (case-insensitive) is false; "true", the empty string, and a bare scroll-to-seek attribute are true; a removed attribute is the default, true, silently. Any other string value is a non-fatal host error and falls back to true. A property write accepts a real boolean directly. Recommended: false. |
| reading-mode | readingMode | boolean | false | Same value-based parsing as scroll-to-seek (default false). Reactive and reflected, like canvas-index: the element's own "Follow along"/"Jump to current" controls, and any property write, write this attribute back through a guarded update that cannot loop. Exposes :state(browsing) on the host while true (alongside the existing :state(playing)/:state(loading)/:state(error)), for CSS like iiif-transcript-player:state(browsing) { ... }. |
| Property (not an attribute) | Type | Notes |
| --------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| annotations | Annotation[] \| "auto" | Default "auto" (see above). Assign a prebuilt array to override. |
| preprocessManifest | (raw: unknown) => unknown | Runs on the manifest JSON before validation. For manifests that need normalizing. |
| errorCallback | (error, { fatal, source }) => void | Same payload as the iiif-player-error event. |
| playerRef | PlayerRef \| null \| undefined (read-only) | Nullish until iiif-player-ready. It stays nullish if the fatal error is a manifest or first-canvas failure; a fatal media error can arrive after iiif-player-ready, in which case playerRef is already set and stays set. |
Attributes vs properties — two rules. Use attributes for initial configuration; a property written onto an element before it is upgraded — before the defining <script> has run, or before customElements.define() is called — is queued and takes effect once the element upgrades, winning over any same-name attribute (the standard custom-element upgrade dance; not a rule this library adds). After the element is upgraded, prefer one channel per key — attributes or properties, not both — and never write the same key twice in one task. An attribute write goes through attributeChangedCallback into the component's props; a property write goes through the generated accessor and sets a local override inside the component, leaving those props holding the previous value. For canvas-index the element repairs that: every canvas switch writes the attribute explicitly, which refreshes the component's props, so attribute, props and property all hold the live index and a later write on either channel still lands. The repair rides on the switch, so a property write that never causes one — an index past the last canvas, or any write made before the manifest resolves — leaves the component's props holding the previous value; the host error tells you when that happened. What no element can repair is ordering — two writes issued in the same task can resolve either way depending on internal flush state, so the same two lines are not guaranteed to give the same result twice. That is a Svelte custom-element property, not a rule we chose.
search-seek-behavior, scroll-to-seek, and reading-mode work through markup, property writes, and property writes made before the element upgrades, same as every other attribute above. scroll-to-seek and reading-mode are the element's own manual value-based owner rather than the generic Boolean-attribute parsing autoplay uses — Svelte's built-in type: "Boolean" is presence-based and cannot tell reading-mode="false" from reading-mode="true", so this library parses and reflects the two by hand.
Recommended: search-seek-behavior="activate" and scroll-to-seek="false".
<iiif-transcript-player
manifest-url="https://example.org/manifest.json"
search-seek-behavior="activate"
scroll-to-seek="false"
></iiif-transcript-player>The default element renders a styled "Follow along" switch and "Jump to current" button in its shadow DOM (unlike the bare Transcript component's unstyled pair) and reflects reading-mode as the reader toggles it, so iiif-transcript-player:state(browsing) { ... } and iiif-transcript-player[reading-mode] { ... } both work from page CSS.
Events
All events bubble and are composed.
| Event | Detail |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| iiif-player-ready | { playerRef } — fired once per connection, after the manifest loads and the first canvas parses (not "playable") |
| iiif-player-error | { error, fatal, source } — source is "manifest" \| "canvas" \| "media" \| "playback" \| "transcript" \| "auth" \| "host" |
| iiif-player-canvas-change | { index, canvas } — from the attribute/property or from the player's own canvas navigation |
| iiif-player-play | {} — playback started (a real transition, not a programmatic prop write) |
| iiif-player-pause | {} — playback stopped without reaching the end (suppressed when the same flush also flipped hasEnded — see iiif-player-ended) |
| iiif-player-ended | {} — playback reached the end of the media |
| iiif-player-seeked | {} — a seek completed (fires on seeked, not on seeking) |
| iiif-player-rate-change | { rate } — the playback rate changed |
No timeupdate-equivalent event ships — too chatty for a bubbling event. Hosts needing fine-grained time reads should use playerRef.state.currentTime (updated on every native timeupdate) instead of listening for an event per frame.
Listen for both iiif-player-ready and iiif-player-error, and branch on detail.fatal: a fatal error means the player will not become usable (replace the element to retry); a non-fatal one (a blocked autoplay, a transcript that failed to load, a misused property) needs no action. iiif-player-error may fire before or after iiif-player-ready. Do not block on playerRef becoming non-nullish.
source: "auth" means the media sits behind an IIIF Auth login. This player does not implement IIIF Auth login flows — it names the failure so your page can send the viewer to the provider's own login or viewer instead.
Reconnection. Moving the element out of the document and back in later — a tab or accordion that detaches its panel, a framework re-parenting a node — rebuilds the player: iiif-player-ready fires again, playerRef is briefly nullish in between, and the manifest is re-read (from the in-memory cache — unless preprocessManifest is set, in which case the manifest is re-fetched over the network and the hook runs again). canvas-index is honoured on the way back in. initial-time is applied only on the element's first connection, so a rebuild does not rewind playback. A synchronous move (remove and re-append in
