Skip to content

Events Reference โ€‹

Complete reference for all events emitted by Movi-Player.

Event Subscription โ€‹

MoviPlayer (Programmatic API) โ€‹

typescript
// Subscribe
player.on("stateChange", (state) => console.log("State:", state));
player.on("loadEnd", () => console.log("Loaded!"));
player.on("durationChange", (duration) => console.log("Duration:", duration));

// Unsubscribe
const handler = (state) => console.log("State:", state);
player.on("stateChange", handler);
player.off("stateChange", handler);

MoviElement (Custom Element) โ€‹

typescript
const element = document.querySelector("movi-player");

// Standard addEventListener
element.addEventListener("stateChange", (e: CustomEvent) => {
  console.log("State:", e.detail);
});

element.addEventListener("durationChange", (e: CustomEvent) => {
  console.log("Duration:", e.detail);
});

Available Events โ€‹

All events from PlayerEventMap:

EventPayloadDescription
loadStartvoidLoading started
loadEndvoidLoading completed
preloadCompletevoidInitial preload buffer filled
stateChangePlayerStateState changed
timeUpdatenumberCurrent time updated
durationChangenumberDuration available/changed
tracksChangeTrack[]Tracks list updated
audioTrackChange{ lang, label }Active audio track switched
subtitleTrackChange{ lang, label } | { lang: null, label: null }Active subtitle track switched (or off)
seekingnumberSeek started (target time)
seekednumberSeek completed (actual time)
bufferUpdate{ start, end }[]Reserved โ€” declared in PlayerEventMap but not emitted yet
coverArtImageBitmap | nullEmbedded cover art extracted at load. Caller owns the bitmap and must call close().
endedvoidPlayback ended
errorErrorError occurred
frameDecodedVideoFrameVideo frame decoded (advanced)
audioDecodedAudioFrameAudio frame decoded (advanced)
subtitleSubtitleCueSubtitle cue active
filerevoked{ offset, length, reason }FileSource handle was revoked by the browser (mobile background / memory pressure)

Lifecycle Events โ€‹

loadStart โ€‹

Fired when loading begins.

typescript
player.on("loadStart", () => {
  showSpinner();
  console.log("Loading video...");
});

loadEnd โ€‹

Fired when loading completes (metadata parsed, ready to play).

typescript
player.on("loadEnd", () => {
  hideSpinner();
  enablePlayButton();
  console.log("Video loaded!");

  // Safe to access tracks now
  renderQualityMenu();
  renderAudioMenu();
});

preloadComplete โ€‹

Fired when the initial preload buffer has enough data to begin playback without stalling. For local files this fires almost immediately; for HTTP sources it fires after the first prefetch window fills.

typescript
player.on("preloadComplete", () => {
  console.log("Buffer ready, safe to play");
  playButton.disabled = false;
});

coverArt โ€‹

Fired when embedded cover art has been extracted from the media file (MP3 ID3v2 APIC, MP4 covr, FLAC PICTURE, MKV attachments). The payload is an ImageBitmap or null if no artwork was found. The caller owns the bitmap and must call close() on it when done.

typescript
player.on("coverArt", (bitmap: ImageBitmap | null) => {
  if (bitmap) {
    artElement.width = bitmap.width;
    artElement.height = bitmap.height;
    artElement.getContext("2d")!.drawImage(bitmap, 0, 0);
    bitmap.close();
  } else {
    artElement.src = "placeholder.png";
  }
});

durationChange โ€‹

Fired when duration becomes available.

typescript
player.on("durationChange", (duration: number) => {
  console.log("Duration:", duration, "seconds");
  timeDuration.textContent = formatTime(duration);
});

ended โ€‹

Fired when playback reaches the end.

typescript
player.on("ended", () => {
  showReplayButton();
  trackVideoComplete();
});

error โ€‹

Fired when an error occurs.

typescript
player.on("error", (error: Error) => {
  console.error("Playback error:", error);
  showErrorMessage(error.message);
  hideSpinner();
});

State Events โ€‹

stateChange โ€‹

Fired when player state changes. This is the primary event for tracking playback state.

typescript
player.on("stateChange", (state: PlayerState) => {
  console.log("State:", state);

  switch (state) {
    case "idle":
      // Initial state, not loaded
      break;
    case "loading":
      showSpinner();
      break;
    case "ready":
      hideSpinner();
      break;
    case "playing":
      updatePlayButton("pause");
      hideSpinner();
      break;
    case "paused":
      updatePlayButton("play");
      break;
    case "buffering":
      showSpinner();
      break;
    case "seeking":
      showSeekIndicator();
      break;
    case "ended":
      showReplayButton();
      break;
    case "error":
      showError();
      break;
  }
});

PlayerState Values โ€‹

StateDescription
idleInitial state, nothing loaded
loadingLoading media file
readyLoaded and ready to play
playingActive playback
pausedPaused
bufferingWaiting for data
seekingSeeking to position
endedPlayback finished
errorError occurred

Progress Events โ€‹

timeUpdate โ€‹

Fired periodically during playback with current time.

typescript
player.on("timeUpdate", (currentTime: number) => {
  const duration = player.getDuration();
  const percent = (currentTime / duration) * 100;

  progressBar.style.width = `${percent}%`;
  timeDisplay.textContent = formatTime(currentTime);
});

seeking โ€‹

Fired when a seek operation begins.

typescript
player.on("seeking", (targetTime: number) => {
  console.log("Seeking to:", targetTime);
  showSeekIndicator();
});

seeked โ€‹

Fired when a seek operation completes.

typescript
player.on("seeked", (actualTime: number) => {
  console.log("Seeked to:", actualTime);
  hideSeekIndicator();
});

bufferUpdate โ€‹

Fired when buffer ranges are updated.

typescript
player.on("bufferUpdate", (ranges: { start: number; end: number }[]) => {
  // Update buffer bar
  if (ranges.length > 0) {
    const lastRange = ranges[ranges.length - 1];
    const bufferPercent = (lastRange.end / player.getDuration()) * 100;
    bufferBar.style.width = `${bufferPercent}%`;
  }
});

Track Events โ€‹

tracksChange โ€‹

Fired when available tracks are updated.

typescript
player.on("tracksChange", (tracks: Track[]) => {
  console.log("Tracks updated:", tracks.length);

  const videoTracks = tracks.filter((t) => t.type === "video");
  const audioTracks = tracks.filter((t) => t.type === "audio");
  const subtitleTracks = tracks.filter((t) => t.type === "subtitle");

  updateQualityMenu(videoTracks);
  updateAudioMenu(audioTracks);
  updateSubtitleMenu(subtitleTracks);
});

audioTrackChange โ€‹

Fired when the active audio track switches (e.g., user picks a different language).

typescript
player.on("audioTrackChange", ({ lang, label }) => {
  console.log("Audio now:", lang, label);
  highlightActiveAudio(lang);
});

Payload: { lang: string; label: string }

subtitleTrackChange โ€‹

Fired when the active subtitle track switches, or when subtitles are turned off.

typescript
player.on("subtitleTrackChange", ({ lang, label }) => {
  if (lang === null) {
    console.log("Subtitles off");
    hideSubtitleIndicator();
  } else {
    console.log("Subtitles now:", lang, label);
    highlightActiveSubtitle(lang);
  }
});

Payload: { lang: string; label: string } when a track is selected, { lang: null, label: null } when subtitles are disabled.

Advanced Events โ€‹

frame โ€‹

Fired for each decoded video frame. Warning: High frequency!

typescript
// Use sparingly - called for every frame
player.on("frame", (frame: DecodedVideoFrame) => {
  console.log("Frame:", frame.timestamp, frame.width, frame.height);

  // Process frame for analysis
  analyzeFrame(frame);
});

interface DecodedVideoFrame {
  timestamp: number;
  duration: number;
  width: number;
  height: number;
  format: "yuv420p" | "rgb24" | "rgba";
  data: Uint8Array;
}

audio โ€‹

Fired for decoded audio frames. Warning: High frequency!

typescript
player.on("audio", (frame: DecodedAudioFrame) => {
  // Process for visualization
  audioVisualizer.update(frame.channelData[0]);
});

interface DecodedAudioFrame {
  timestamp: number;
  duration: number;
  sampleRate: number;
  channels: number;
  numFrames: number;
  format: "f32-planar";
  channelData: Float32Array[];
}

subtitle โ€‹

Fired when a subtitle cue becomes active.

typescript
player.on("subtitle", (cue: SubtitleCue) => {
  if (cue.text) {
    showSubtitle(cue.text);
  } else if (cue.image) {
    showSubtitleImage(cue.image);
  }
});

interface SubtitleCue {
  start: number;
  end: number;
  text?: string;
  image?: ImageBitmap;
  position?: { x: number; y: number };
}

Event Flow โ€‹

player.load() called
    โ”‚
    โ”œโ”€โ–บ loadStart
    โ”‚
    โ”œโ”€โ–บ stateChange ('loading')
    โ”‚
    โ”œโ”€โ–บ durationChange (duration)
    โ”‚
    โ”œโ”€โ–บ tracksChange (tracks)
    โ”‚
    โ”œโ”€โ–บ loadEnd
    โ”‚
    โ””โ”€โ–บ stateChange ('ready')

player.play() called
    โ”‚
    โ”œโ”€โ–บ stateChange ('playing')
    โ”‚
    โ””โ”€โ–บ timeUpdate (repeats during playback)

player.seek(60) called
    โ”‚
    โ”œโ”€โ–บ seeking (60)
    โ”‚
    โ”œโ”€โ–บ stateChange ('seeking')
    โ”‚
    โ”œโ”€โ–บ seeked (60)
    โ”‚
    โ””โ”€โ–บ stateChange ('playing')

player.pause() called
    โ”‚
    โ””โ”€โ–บ stateChange ('paused')

Video ends
    โ”‚
    โ”œโ”€โ–บ ended
    โ”‚
    โ””โ”€โ–บ stateChange ('ended')

Complete Example โ€‹

typescript
import { MoviPlayer, LogLevel } from "movi-player/player";

MoviPlayer.setLogLevel(LogLevel.ERROR);

const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const player = new MoviPlayer({
  source: { type: "url", url: "video.mp4" },
  canvas: canvas,
});

// UI elements
const spinner = document.getElementById("spinner");
const playBtn = document.getElementById("playBtn");
const progressBar = document.getElementById("progressBar");
const timeDisplay = document.getElementById("time");

// Loading events
player.on("loadStart", () => {
  spinner.style.display = "block";
});

player.on("loadEnd", () => {
  spinner.style.display = "none";
});

player.on("durationChange", (duration) => {
  timeDisplay.dataset.duration = String(duration);
});

// State events
player.on("stateChange", (state) => {
  if (state === "playing") {
    playBtn.textContent = "โธ";
    spinner.style.display = "none";
  } else if (state === "paused") {
    playBtn.textContent = "โ–ถ";
  } else if (state === "buffering") {
    spinner.style.display = "block";
  } else if (state === "ended") {
    playBtn.textContent = "โ†บ";
  }
});

// Progress
player.on("timeUpdate", (currentTime) => {
  const duration = player.getDuration();
  progressBar.style.width = `${(currentTime / duration) * 100}%`;
  timeDisplay.textContent = formatTime(currentTime);
});

// Errors
player.on("error", (error) => {
  console.error("Error:", error);
  spinner.style.display = "none";
  alert(`Playback error: ${error.message}`);
});

// Load and play
await player.load();
await player.play();

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

MoviElement DOM Events โ€‹

The custom element re-exposes player activity as DOM events so you can wire addEventListener(...) like a native <video>. Names use HTML-style lowercase where they map to a standard media event, and stay as-is for player-specific extras.

EventDetail payloadDescription
loadstart{ src: string | null }A new source is being loaded
abortโ€”A load was stopped before it had data
suspendโ€”Preload settled; no more bytes are being pulled
encrypted{ initDataType, tokenUrl, videoUrl }The source needs a key; the handshake is starting
waitingforkeyโ€”Playback is held until the licence lands
cuechangeโ€”Fired on the TextTrack, not on the element
loadedmetadataโ€”Duration and track list are known
loadeddataโ€”First frame is decoded and ready to render
canplayโ€”Enough data buffered to begin playback
durationchangenumber (seconds)Duration became known, or was corrected mid-playback
playโ€”Playback started
playingโ€”Playback actually resumed (after a stall or start)
waitingโ€”Playback stalled waiting for data
pauseโ€”Playback paused
seekingnumber (target time)A seek started
seekednumber (landed time)The seek completed
progressnumber (buffered end, seconds)Fetching advanced the buffered end
canplaythroughโ€”Buffer reached the end of the media. Unlike <video>, this is not an estimate โ€” it fires only when the rest is genuinely buffered, at most once per source
stalledโ€”No data arrived for ~3s while fetching
emptiedโ€”Previous media torn down; a new load is starting
resize{ width: number, height: number }Intrinsic video size changed (i.e. a quality switch)
endedโ€”Playback reached the end
timeupdatenumber (current time)Current time advanced (fires repeatedly)
errorErrorInternal player error surfaced to the DOM
errordisplay{ title, message, canRetry, canTrySoftware }An error screen went up, with the wording on it. Unlike error, whose payload is the raw Error, this is what the viewer is shown โ€” and it also covers the format/codec failures that raise no runtime error. See Customizing the Error Screen
statechangePlayerStateUnderlying MoviPlayer state transitioned
volumechange{ volume: number, muted: boolean }Volume or mute toggled (UI, hotkey, or property)
ratechange{ playbackRate: number }Playback speed changed
titlechange{ title: string | null }Resolved/cleaned video title changed
audiotrackchangeโ€”Active audio track switched
subtitletrackchangeโ€”Active subtitle track switched
trackschangeTrack[]Available tracks list updated
fullscreenchange{ fullscreen: boolean }Player entered/exited fullscreen
movi-fullscreen-requestโ€”Cancelable โ€” fires before requestFullscreen(). preventDefault() blocks it so a host can take over via setHostFullscreen()
pipchange{ pip: boolean }Picture-in-Picture window opened/closed
enterpictureinpictureโ€”HTMLVideoElement alias, fired alongside pipchange
leavepictureinpictureโ€”HTMLVideoElement alias, fired alongside pipchange
qualitychange{ trackId: number }Active video quality / track switched
subtitledelaychange{ subtitleDelay: number }Subtitle offset changed via property/attribute
coverartImageBitmap | nullEmbedded cover art extracted at load (close the bitmap when done)
preloadcompleteโ€”Initial preload buffer filled, ready to play
linearmodeโ€”Source server ignores Range (200, not 206) โ€” playback is forward-only via a sliding RAM window; hide seek-dependent UI
audiotrackchangeโ€”Active audio track switched
audiooutputchange{ deviceId: string | null }Audio output device (sink) changed
audiostripchange{ active: boolean }Audio-only strip layout entered/left
nativefallback{ src: string }Source handed to a native <video> (fallback="native")
movi-qoeQoE snapshotPlayback-quality telemetry sample
filerevoked{ offset, length, reason }Underlying File handle was revoked by the browser (mobile background / memory pressure). Prompt the user to re-pick.

Casing note

subtitletrackchange is the canonical name, matching every other DOM event here. A camelCase subtitleTrackChange is dispatched alongside it as a compatibility alias โ€” earlier versions of this page documented only that spelling. Prefer the lowercase one.

Parity with <video> โ€‹

The element aims to be drop-in for code written against a native media element. Every HTMLMediaElement event above behaves the same way, with these deliberate exceptions:

Native eventStatusWhy
canplaythroughStricter<video> fires it on a heuristic estimate. We fire it only when the media is genuinely buffered to the end, so it may arrive later โ€” or, on a slow link, not at all. Gate optional UI on it, never playback.
abortFiresA load counts as in flight until it has data, not merely while the loading flag is up, so a source taken away mid-open reports it. Ordered before emptied, as the spec has it.
suspendFiresRaised when preload settles โ€” the moment this player stops pulling bytes on purpose.
encryptedFires, differently<video> raises it on meeting initialisation data it needs a key for. This player is told up front, so it fires where the key handshake begins: the load about to ask the token endpoint.
waitingforkeyFiresFollows encrypted โ€” playback is held until the token lands.
cuechangeFiresOn the mirrored TextTrack whose caption changed, which is where native fires it.

seeking / seeked / timeupdate / durationchange / progress / resize carry a detail payload (see the table) where <video> carries none โ€” read the property off the element instead if you want identical code across both.

Subscribing โ€‹

typescript
const el = document.querySelector("movi-player")!;

el.addEventListener("loadstart", (e: CustomEvent) => {
  console.log("Loading:", e.detail.src);
});

el.addEventListener("timeupdate", (e: CustomEvent<number>) => {
  progressBar.style.width = `${(e.detail / el.duration) * 100}%`;
});

el.addEventListener("statechange", (e: CustomEvent) => {
  if (e.detail === "buffering") showSpinner();
  else hideSpinner();
});

el.addEventListener("volumechange", (e: CustomEvent) => {
  volumeIcon.dataset.muted = String(e.detail.muted);
});

el.addEventListener("pipchange", (e: CustomEvent) => {
  pipButton.dataset.active = String(e.detail.pip);
});

el.addEventListener("titlechange", (e: CustomEvent) => {
  document.title = e.detail.title ?? "Movi";
});