> ## 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 Vue and Nuxt

> Embed the Bunny Stream player in a Vue or Nuxt app and control playback with player.js events and methods.

This guide builds a Vue `BunnyPlayer` component that renders the Bunny Player iframe, emits its events, and exposes a [player.js](https://github.com/embedly/player.js) `Player` for controlling playback.

Nuxt can server-render it as is. player.js reads `window` on import, which is why the component imports it in `onMounted`, and the iframe waits for the library before it renders.

> **[Vue example on GitHub](https://github.com/BunnyWay/examples/tree/main/stream/player-vue)**
>
> A Vite app with custom controls and an event log.

> **[Nuxt example on GitHub](https://github.com/BunnyWay/examples/tree/main/stream/player-nuxt)**
>
> The same component in a Nuxt 4 app.

## Quickstart

**1. Install player.js**

```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
```

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;
}
```

**2. Create the component**

```vue components/BunnyPlayer.vue theme={null}
<script setup lang="ts">
import { computed, onMounted, ref, shallowRef, watch } from "vue";
import type { Player, TimeUpdate } from "player.js";

type PlayerJs = (typeof import("player.js"))["default"];

const props = withDefaults(
  defineProps<{
    libraryId: string;
    videoId: string;
    params?: Record<string, string | number | boolean>;
    title?: string;
  }>(),
  { title: "Video player" },
);

const emit = defineEmits<{
  ready: [player: Player];
  play: [];
  pause: [];
  ended: [];
  timeupdate: [time: TimeUpdate];
}>();

const iframe = ref<HTMLIFrameElement | null>(null);
const playerjs = shallowRef<PlayerJs | null>(null);
const player = shallowRef<Player | null>(null);

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

onMounted(async () => {
  // player.js reads window when imported, so load it in the browser only.
  playerjs.value = (await import("player.js")).default;
});

// The iframe is keyed by src, so this runs for every new iframe element,
// right after it is inserted and before it has finished loading.
watch(
  iframe,
  (el, _previous, onCleanup) => {
    const pjs = playerjs.value;
    if (!el || !pjs) return;

    // player.js never removes the window listener each Player adds,
    // so grab it while constructing and remove it on cleanup.
    let onMessage: EventListener = () => {};
    const addEvent = pjs.addEvent;
    pjs.addEvent = (elem, type, handler) => addEvent(elem, type, (onMessage = handler));
    const instance = new pjs.Player(el);
    pjs.addEvent = addEvent;

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

    player.value = instance;
    onCleanup(() => {
      window.removeEventListener("message", onMessage);
      player.value = null;
    });
  },
  { flush: "post" },
);

defineExpose({ player });
</script>

<template>
  <iframe
    v-if="playerjs"
    ref="iframe"
    :key="src"
    :src="src"
    :title="title"
    class="bunny-player"
    allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
    allowfullscreen
  />
  <!-- Hold the space until player.js is loaded so the iframe cannot
       finish loading before the Player exists. -->
  <div v-else class="bunny-player" aria-hidden="true" />
</template>

<style scoped>
.bunny-player {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  border: 0;
  background: #000;
}
</style>
```

In Nuxt, save it under `components/` and it's auto-imported. Skip `<ClientOnly>`: the server renders the placeholder and the browser swaps in the iframe.

**3. Render a video**

The library ID and video GUID are on the video's page in the dashboard.

```vue theme={null}
<script setup lang="ts">
import BunnyPlayer from "./components/BunnyPlayer.vue";
</script>

<template>
  <BunnyPlayer
    library-id="12345"
    video-id="your-video-guid"
    :params="{ autoplay: false, preload: true }"
    @ended="console.log('Video finished')"
  />
</template>
```

`params` takes any [player parameter](/docs/stream/embedding#supported-parameters), such as `captions`, `t`, or `muted`.

## Control playback

The `ready` event carries the `Player`. Keep it in a `shallowRef`, which stops Vue wrapping it in a deep reactive proxy.

```vue theme={null}
<script setup lang="ts">
import { ref, shallowRef } from "vue";
import type { Player } from "player.js";
import BunnyPlayer from "./components/BunnyPlayer.vue";

const player = shallowRef<Player | null>(null);
const playing = ref(false);

function toggle() {
  playing.value ? player.value?.pause() : player.value?.play();
}
</script>

<template>
  <BunnyPlayer
    library-id="12345"
    video-id="your-video-guid"
    @ready="player = $event"
    @play="playing = true"
    @pause="playing = false"
  />
  <button @click="toggle">{{ playing ? "Pause" : "Play" }}</button>
  <button @click="player?.setCurrentTime(0)">Restart</button>
  <button @click="player?.mute()">Mute</button>
</template>
```

Getters answer through a callback, since the value comes back from the iframe.

```ts theme={null}
player.value?.getCurrentTime((seconds) => console.log(seconds));
player.value?.getDuration((seconds) => console.log(seconds));
```

Playback speed is missing from the npm build (0.1.0), and `send()` covers the gap. The build we host adds `setPlaybackRate()`, `getPlaybackRate()` and `playbackratechange` ([Methods](/docs/stream/playback-api#methods)).

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

Browsers block unmuted `play()` before the viewer has clicked anything. Call `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.

```vue theme={null}
<script setup lang="ts">
import BunnyPlayer from "./components/BunnyPlayer.vue";

const props = defineProps<{ videoId: string; resumeAt?: number }>();
let lastSaved = 0;

function onTimeUpdate({ seconds, duration }: { seconds: number; duration: number }) {
  if (Math.abs(seconds - lastSaved) < 5) return;
  lastSaved = seconds;
  saveProgress(props.videoId, seconds, duration);
}
</script>

<template>
  <BunnyPlayer
    library-id="12345"
    :video-id="videoId"
    :params="resumeAt ? { t: resumeAt } : undefined"
    @timeupdate="onTimeUpdate"
    @ended="markComplete(videoId)"
  />
</template>
```

Pass the saved position back as the `t` parameter to resume.

## Load player.js from the CDN instead

To drop the npm dependency, load our hosted build from the component.

```ts 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 dynamic import in `onMounted` for the loader.

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

onMounted(async () => {
  playerjs.value = await loadPlayerjs();
});
```

## Troubleshooting

> **ReferenceError: window is not defined**
>
> player.js is being imported during server rendering. Keep `import("player.js")` inside `onMounted`. A static `import playerjs from "player.js"` at the top of `<script setup>` runs on the server in Nuxt.

> **The ready event never fires**
>
> The iframe loaded before player.js. Render it only once player.js has loaded, as the component above does.
>
> A hidden tab also holds `ready` back until the viewer switches to it.

> **Nuxt warns that a type check failed for libraryId**
>
> Nuxt parses `NUXT_PUBLIC_*` variables as it loads them, and a numeric library ID such as `12345` arrives as a number. Convert the IDs to strings first.
>
> ```vue theme={null}
> <script setup lang="ts">
> const config = useRuntimeConfig().public;
> const libraryId = String(config.bunnyLibraryId);
> const videoId = String(config.bunnyVideoId);
> </script>
> ```

> **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).


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