Recipes

Write a custom plugin

Track CTA clicks to your analytics endpoint:

import { Playerstack } from "@playerstack/core";
import type { PlayerstackPlugin } from "@playerstack/core";

const analyticsPlugin: PlayerstackPlugin = {
  name: "analytics",
  init(ctx) {
    ctx.events.on("cta:click", ({ label, url }) => {
      navigator.sendBeacon(
        "/api/track",
        JSON.stringify({
          event: "cta_click",
          label,
          url,
          productId: ctx.container.dataset.productId,
        }),
      );
    });
  },
};

Playerstack.use(analyticsPlugin).define();

The plugin doesn’t render anything — it only listens to the event bus. Other plugins emit events; you wire them to your backend.

Per-element state with WeakMap

If your plugin holds state per <player-stack>, use WeakMap<PlayerstackContext, ...>:

const states = new WeakMap<PlayerstackContext, MyState>();

const myPlugin: PlayerstackPlugin = {
  name: "my-plugin",
  init(ctx) {
    states.set(ctx, {
      /* ... */
    });
  },
  destroy(ctx) {
    states.get(ctx)?.cleanup();
    states.delete(ctx);
  },
};

This is what every first-party plugin does. Without it, putting two players on one page corrupts state.

Custom config provider

When you want config to come from somewhere other than inline attributes — a CMS, a CDN, your own API:

import type { ConfigProvider, PlayerstackConfig } from "@playerstack/core";
import { setConfigProviderForElement } from "@playerstack/core";

const cmsProvider: ConfigProvider = {
  name: "cms",
  async load(element) {
    const slug = element.dataset.cmsSlug;
    if (!slug) throw new Error("no data-cms-slug");
    const response = await fetch(`/api/videos/${slug}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return (await response.json()) as PlayerstackConfig;
  },
};

setConfigProviderForElement(cmsProvider);
<player-stack data-cms-slug="my-lesson"></player-stack>

<player-stack> calls cmsProvider.load(element) on connect, then mounts with the returned config.

Shrink the bundle

If 127 KB gzipped is too much, ship only the plugins you use:

import { Playerstack } from "@playerstack/core";
import { sourcesPlugin } from "@playerstack/plugin-sources";
// no controls-policy, no autopreview, no cta-end, etc.

Playerstack.use(sourcesPlugin).define();

Each plugin you skip saves a few KB. For a true minimum, you can skip media-chrome entirely and use the inner media element with the browser’s default controls (controls attribute) — but that’s a bigger refactor.

Dynamic config

You can mount and re-mount a <player-stack> with different config:

const el = document.querySelector("player-stack");
el?.remove();
const fresh = document.createElement("player-stack");
fresh.setAttribute("src", newSrc);
fresh.setAttribute("data-config", JSON.stringify(newConfig));
document.body.appendChild(fresh);

disconnectedCallback runs destroy(ctx) on every plugin, then a fresh connectedCallback runs init(ctx) on the new element. State is fully isolated.

Hook into the lifecycle

Listen to events on the underlying media element (<video>, <youtube-video>, <vimeo-video>, or <hls-video>) directly:

const myPlugin: PlayerstackPlugin = {
  name: "tracker",
  init(ctx) {
    const handler = () => console.log("video ended");
    ctx.mediaPlayer.addEventListener("ended", handler);
    // store handler ref so destroy can remove it
    states.set(ctx, { handler });
  },
  destroy(ctx) {
    const state = states.get(ctx);
    if (state) ctx.mediaPlayer.removeEventListener("ended", state.handler);
    states.delete(ctx);
  },
};

Every Mux media-element wraps a real <video> (or iframe for YT/Vimeo) and dispatches the standard HTML5 media events: play, pause, ended, timeupdate, canplay, loadeddata, volumechange, ratechange, durationchange, seeked. See MDN’s HTMLMediaElement events list for the full set.