> ## Documentation Index
> Fetch the complete documentation index at: https://bunny.net/docs/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: unknown[]) => void): void;
        supports(kind: "method" | "event", name: string | string[]): boolean;
        /** Send a raw command, for methods player.js does not expose such as setPlaybackRate. */
        send(message: { method: string; value?: unknown }): 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 };
      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;
      /** Player parameters such as autoplay, muted, captions, or t. */
      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 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](/docs/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](/docs/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 (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](/docs/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](/docs/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](/docs/stream/playback-api#methods)). Load it in your layout's `<head>` with `is:inline`, which keeps Astro from bundling it and runs it ahead of the component's script.

```astro theme={null}
<script is:inline src="https://assets.mediadelivery.net/playerjs/playerjs-latest.min.js"></script>
```

Swap the import for `const playerjs = window.playerjs` and declare the global.

```ts theme={null}
declare global {
  interface Window {
    playerjs: (typeof import("player.js"))["default"];
  }
}
```

## 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](/docs/stream/embedding#embedding-restrictions).
  </Accordion>
</AccordionGroup>


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