> ## Documentation Index
> Fetch the complete documentation index at: https://bunnynet-cb9733c2-nathan-draft-sep-8.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Use Bunny Player with Astro

> Embed the Bunny Stream player in an Astro site with a custom element, and control playback with player.js events and methods.

Astro renders the Bunny Player iframe on the server and ships no JavaScript until you ask for some. This guide asks for very little: a `<bunny-player>` custom element that attaches [player.js](https://github.com/embedly/player.js) and turns the player's events into DOM events.

<Card title="Astro example on GitHub" icon="github" href="https://github.com/BunnyWay/examples/tree/main/stream/player-astro" horizontal>
  An Astro site with custom controls and an event log.
</Card>

## Quickstart

<Steps>
  <Step title="Install player.js">
    <CodeGroup>
      ```bash npm theme={null}
      npm install player.js
      ```

      ```bash pnpm theme={null}
      pnpm add player.js
      ```

      ```bash yarn theme={null}
      yarn add player.js
      ```

      ```bash bun theme={null}
      bun add player.js
      ```
    </CodeGroup>

    player.js ships without types. Add a declaration file anywhere your `tsconfig.json` includes, for example `player.js.d.ts`. It covers the methods and events the Bunny Player supports:

    ```ts player.js.d.ts theme={null}
    declare module "player.js" {
      export type PlayerEvent =
        | "ready"
        | "play"
        | "pause"
        | "ended"
        | "timeupdate"
        | "progress"
        | "seeked"
        | "error"
        | "playbackratechange";

      export type TimeUpdate = { seconds: number; duration: number };
      export type Progress = { percent: number; seconds: number; duration: number };
      /** Present when a command fails. Empty when the media itself errors. */
      export type PlayerError = { code: number; msg: string };

      export class Player {
        constructor(iframe: HTMLIFrameElement | string);

        on(event: "ready", callback: () => void): void;
        on(event: "timeupdate", callback: (data: TimeUpdate) => void): void;
        on(event: "progress", callback: (data: Progress) => void): void;
        on(event: "playbackratechange", callback: (rate: number) => void): void;
        on(event: "error", callback: (error?: PlayerError) => void): void;
        on(event: PlayerEvent, callback: (data?: unknown) => void): void;
        off(event: PlayerEvent, callback?: (...args: never[]) => void): void;
        supports(kind: "method" | "event", name: string | string[]): boolean;
        /** Send a raw command, for methods player.js does not expose such as setPlaybackRate. Getters answer through the callback. */
        send(message: { method: string; value?: unknown }, callback?: (value: unknown) => void): void;

        play(): void;
        pause(): void;
        mute(): void;
        unmute(): void;
        setVolume(percent: number): void;
        setCurrentTime(seconds: number): void;
        setLoop(loop: boolean): void;

        getPaused(callback: (paused: boolean) => void): void;
        getMuted(callback: (muted: boolean) => void): void;
        getVolume(callback: (percent: number) => void): void;
        getDuration(callback: (seconds: number) => void): void;
        getCurrentTime(callback: (seconds: number) => void): void;
        getLoop(callback: (loop: boolean) => void): void;
      }

      const playerjs: {
        Player: typeof Player;
        addEvent(elem: EventTarget, type: string, handler: EventListener): void;
      };
      export default playerjs;
    }
    ```
  </Step>

  <Step title="Create the component">
    The embed URL waits in `data-src` until the element has a `Player` listening. An iframe with `src` in the HTML can finish loading first, and then `ready` never arrives.

    ```astro src/components/BunnyPlayer.astro theme={null}
    ---
    type Props = {
      libraryId: string;
      videoId: string;
      params?: Record<string, string | number | boolean>;
      title?: string;
    };

    const { libraryId, videoId, params = {}, title = "Video player" } = Astro.props;

    const query = new URLSearchParams(
      Object.entries(params).map(([key, value]) => [key, String(value)]),
    ).toString();
    const src = `https://player.mediadelivery.net/embed/${libraryId}/${videoId}${query ? `?${query}` : ""}`;
    ---

    <bunny-player>
      <iframe
        data-src={src}
        title={title}
        loading="lazy"
        allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
        allowfullscreen></iframe>
    </bunny-player>

    <script>
      // Astro runs this in the browser only, so player.js can read window on import.
      import playerjs, { type Player } from "player.js";

      class BunnyPlayerElement extends HTMLElement {
        player: Player | null = null;

        connectedCallback() {
          const iframe = this.querySelector("iframe");
          if (this.player || !iframe?.dataset.src) return;

          iframe.src = iframe.dataset.src;
          const player = new playerjs.Player(iframe);
          this.player = player;

          player.on("ready", () => this.emit("ready", player));
          player.on("play", () => this.emit("play"));
          player.on("pause", () => this.emit("pause"));
          player.on("ended", () => this.emit("ended"));
          player.on("timeupdate", (time) => this.emit("timeupdate", time));
        }

        private emit(type: string, detail?: unknown) {
          this.dispatchEvent(new CustomEvent(type, { detail }));
        }
      }

      customElements.define("bunny-player", BunnyPlayerElement);
    </script>

    <style>
      bunny-player {
        display: block;
      }

      iframe {
        display: block;
        width: 100%;
        aspect-ratio: 16 / 9;
        border: 0;
        background: #000;
      }
    </style>
    ```
  </Step>

  <Step title="Add your video IDs">
    Copy the library ID and video GUID from the video's page in the dashboard. Both are public, since they appear in every embed URL.

    ```bash .env theme={null}
    PUBLIC_BUNNY_LIBRARY_ID=12345
    PUBLIC_BUNNY_VIDEO_ID=your-video-guid
    ```

    Astro inlines `PUBLIC_` variables at build time. Without them, the embed URL reads `undefined/undefined`.
  </Step>

  <Step title="Add it to a page">
    ```astro src/pages/index.astro theme={null}
    ---
    import BunnyPlayer from "../components/BunnyPlayer.astro";
    ---

    <BunnyPlayer
      libraryId={import.meta.env.PUBLIC_BUNNY_LIBRARY_ID}
      videoId={import.meta.env.PUBLIC_BUNNY_VIDEO_ID}
      params={{ preload: true }}
    />
    ```

    `params` takes any [player parameter](/stream/embedding#supported-parameters).
  </Step>
</Steps>

## Control playback

`ready` hands over the `Player` as `event.detail`.

```astro theme={null}
<BunnyPlayer libraryId={libraryId} videoId={videoId} />
<button type="button" id="toggle">Play / pause</button>
<button type="button" id="restart">Restart</button>

<script>
  import type { Player } from "player.js";

  document.querySelector("bunny-player")!.addEventListener("ready", (event) => {
    const player = (event as CustomEvent<Player>).detail;

    document.querySelector("#toggle")!.addEventListener("click", () => {
      // Ask the player, because viewers can also use its own controls.
      player.getPaused((paused) => (paused ? player.play() : player.pause()));
    });
    document.querySelector("#restart")!.addEventListener("click", () => player.setCurrentTime(0));
  });
</script>
```

Getters answer through a callback, since the value comes back from the iframe. Playback speed is missing from the npm build (0.1.0), and `send()` covers the gap:

```ts theme={null}
player.send({ method: "setPlaybackRate", value: 1.5 });
```

Browsers block unmuted `play()` before the viewer has clicked anything. Mute first if playback has to start on its own. The [Playback control API](/stream/playback-api) lists every method and event.

## Track progress

`timeupdate` fires several times a second. Throttle it before it reaches your backend.

```ts theme={null}
import type { TimeUpdate } from "player.js";

let lastSaved = 0;

document.querySelector("bunny-player")!.addEventListener("timeupdate", (event) => {
  const { seconds, duration } = (event as CustomEvent<TimeUpdate>).detail;
  if (Math.abs(seconds - lastSaved) < 5) return;
  lastSaved = seconds;
  void saveProgress(seconds, duration);
});
```

Pass the saved position back as `params={{ t: savedSeconds }}` to resume.

## Signed embed URLs

With [embed view token authentication](/stream/token-authentication) on, sign the URL in the page frontmatter and pass `token` and `expires` through `params`. Render that page [on demand](https://docs.astro.build/en/guides/on-demand-rendering/). A prerendered page hands every visitor the same token, and it expires. The signing code is in [Sign embed URLs on the server](/stream/player/signed-embeds).

## Load player.js from the CDN instead

We host a build of player.js that adds `setPlaybackRate()` and the `playbackratechange` event ([Methods](/stream/playback-api#methods)). Load it from the component, so only pages with a player fetch it.

```ts src/lib/load-playerjs.ts theme={null}
type PlayerJs = (typeof import("player.js"))["default"];

declare global {
  interface Window {
    playerjs: PlayerJs;
  }
}

let loading: Promise<PlayerJs> | undefined;

export function loadPlayerjs() {
  loading ??= new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://assets.mediadelivery.net/playerjs/playerjs-latest.min.js";
    script.onload = () => resolve(window.playerjs);
    script.onerror = (error) => {
      loading = undefined;
      reject(error);
    };
    document.head.append(script);
  });
  return loading;
}
```

Swap the `player.js` import in `BunnyPlayer.astro` for the loader, and wait for it before setting `src`.

```ts src/components/BunnyPlayer.astro theme={null}
import { loadPlayerjs } from "../lib/load-playerjs";

async connectedCallback() {
  const iframe = this.querySelector("iframe");
  const src = iframe?.dataset.src;
  if (!iframe || !src) return;
  delete iframe.dataset.src;

  const playerjs = await loadPlayerjs();
  iframe.src = src;
  const player = new playerjs.Player(iframe);
  this.player = player;
  // ...register events as before
}
```

## Troubleshooting

<AccordionGroup>
  <Accordion title="The ready event never fires">
    The iframe loaded before player.js was listening. Keep the URL in `data-src` and let the element set `src`.

    A hidden tab also holds `ready` back until the viewer switches to it.
  </Accordion>

  <Accordion title="Controls stop working after a view transition">
    `<ClientRouter />` runs page scripts once, leaving your `ready` listener on the previous page's element. Register it inside an `astro:page-load` listener.
  </Accordion>

  <Accordion title="The iframe shows a 403">
    The library's allowed domains, direct access block, or token authentication is rejecting the embed. See [Embedding restrictions](/stream/embedding#embedding-restrictions).
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.