Build a custom quality selector

Build a custom video quality selector UI with the FastPix Player JavaScript API, slot-based overlays, and quality change events.

The FastPix Player exposes a JavaScript API for reading and switching video quality levels, which lets you build a branded quality selector that gives viewers explicit resolution control while preserving adaptive bitrate (ABR) fallback for unstable networks. Combine the quality methods with slot-based overlays to position your custom UI directly over the video without managing absolute positioning yourself.


Key terms

  • Rendition is one resolution/bitrate variant in the HLS manifest that FastPix generates during transcoding (for example, 360p at 800 kbps, 720p at 2.5 Mbps).

  • Adaptive Bitrate Streaming (ABR) is the default mode where the player switches renditions automatically based on the viewer's network speed.

  • playbackId is the access-controlled identifier FastPix assigns for constructing the HLS playback URL: https://stream.fastpix.io/{PLAYBACK_ID}.m3u8.


Retrieve available quality levels

Call player.getQualityLevels() after the manifest loads to get the full rendition ladder.

const levels = player.getQualityLevels();
// Returns: [{ id, label, height, width, bitrate, frameRate }, ...]

Each entry contains:

PropertyTypeDescription
idnumberPass this to setQualityLevel() to lock playback to this rendition
labelstringHuman-readable label (for example, "720p") - display this in your UI
heightnumberVertical resolution in pixels
widthnumberHorizontal resolution in pixels
bitratenumberBits per second for this rendition
frameRatenumberFrames per second

NOTE

The array is empty for audio-only streams or single-rendition videos. Wait for the fastpixqualitylevelsready event before calling this method on a new video.


Lock playback to a specific resolution

Call player.setQualityLevel(id) to disable ABR and force the player to use one rendition.

player.setQualityLevel(2); // Lock to the rendition with id 2 (e.g. "720p")

The id value comes from a getQualityLevels() entry. Do not hard-code IDs across videos, each manifest generates its own ID set.


Re-enable adaptive bitrate

Call player.setQualityAuto() to hand control back to the ABR engine. The player resumes switching renditions based on network conditions.

player.setQualityAuto();

Use this when the viewer selects "Auto" in your menu, or as a fallback after a quality error.


Read the current quality state

Call player.getPlaybackQuality() to get a snapshot of the player's current quality mode and active rendition.

const q = player.getPlaybackQuality();
// Returns: { mode, lockedLevel, loadedLevel }
PropertyTypeDescription
mode"auto" or "manual""auto" = ABR active; "manual" = viewer locked a rendition
lockedLevelobject or nullThe rendition the viewer locked to, or null in auto mode
loadedLevelobjectThe rendition currently being decoded and played
const q = player.getPlaybackQuality();
if (q.mode === "auto") {
  // Highlight the "Auto" button
} else if (q.lockedLevel) {
  // Highlight the button matching q.lockedLevel.id
}
console.log("Currently playing:", q.loadedLevel?.label); // e.g. "720p"

NOTE

loadedLevel reflects what is actually on screen. Under ABR, it can differ from lockedLevel briefly during a rendition switch.


Listen for quality events

The player triggers three custom events on the <fastpix-player> element. Listen with addEventListener.

fastpixqualitylevelsready

Triggers when the HLS manifest is parsed and quality levels are available. Also triggers again if a new video loads.

player.addEventListener("fastpixqualitylevelsready", (e) => {
  const levels = e.detail.levels;
  // Build your quality menu buttons here
});

event.detail contains:

{
  "levels": [{ "id": 0, "label": "360p", "height": 360, "width": 640, "bitrate": 800000, "frameRate": 30 }]
}

fastpixqualitychange

Triggers whenever the quality state changes, either from a viewer action (setQualityLevel / setQualityAuto) or from ABR switching renditions automatically.

player.addEventListener("fastpixqualitychange", (e) => {
  console.log("Quality changed to:", e.detail.loadedLevel?.label);
  console.log("Mode:", e.detail.mode);
  // Re-highlight your menu buttons here
});

event.detail contains:

PropertyDescription
mode"auto" or "manual"
lockedLevelThe locked rendition, or null
loadedLevelThe rendition now playing
previousLoadedLevelThe rendition before this change

fastpixqualityfailed

Triggers when a quality change fails, either an invalid ID was passed to setQualityLevel() or a specific rendition failed to load from the network.

player.addEventListener("fastpixqualityfailed", (e) => {
  console.warn("Quality failed:", e.detail.reason);
  player.setQualityAuto(); // Fall back to ABR
});

event.detail contains:

PropertyDescription
reasonHuman-readable error string
levelIdThe ID that failed (if applicable)
rawRaw error from hls.js (for debugging)

Build the full example

A complete, working quality selector combining every API and event described in this guide. The example uses slot overlays to position the menu in the top-right corner of the video. Copy the HTML below, replace your-playback-id with a real playbackId, and open it in a browser.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Custom Quality Selector - FastPix Player</title>
  <script defer src="https://cdn.jsdelivr.net/npm/@fastpix/fp-player@latest"></script>
  <style>
    /* ── Page layout ─────────────────────────────────────────── */
    body {
      font-family: system-ui, sans-serif;
      max-width: 800px;
      margin: 32px auto;
      padding: 0 16px;
      background: #f9f9f9;
    }

    /* ── Player wrapper ──────────────────────────────────────── */
    .player-shell {
      position: relative;
      width: 100%;
      aspect-ratio: 16 / 9;
      border-radius: 10px;
      overflow: hidden;
      background: #000;
    }

    fastpix-player {
      width: 100%;
      height: 100%;
      display: block;

      /* Hide the built-in resolution button so only the custom menu shows */
      --resolution-selector: none;

      /* Raise the slot layer above built-in player chrome */
      --user-slot-z: 2001;

      /* Keep bottom-row slots above the seek bar */
      --user-slot-bottom-clearance: 48px;
    }

    /* ── Quality toggle button (inside slot="top-right") ──────── */
    .quality-toggle {
      padding: 7px 14px;
      font-size: 13px;
      font-weight: 600;
      cursor: pointer;
      border: none;
      border-radius: 6px;
      background: rgba(0, 0, 0, 0.6);
      color: #fff;
      backdrop-filter: blur(6px);
    }
    .quality-toggle:hover {
      background: rgba(40, 40, 40, 0.85);
    }
    .quality-toggle[aria-expanded="true"] {
      background: #2563eb;
    }

    /* ── Dropdown panel ──────────────────────────────────────── */
    .quality-panel {
      display: none;
      flex-direction: column;
      gap: 5px;
      min-width: 150px;
      max-height: 260px;
      overflow-y: auto;
      padding: 8px;
      border-radius: 8px;
      background: rgba(15, 15, 15, 0.92);
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
      backdrop-filter: blur(8px);
      margin-top: 6px;
    }
    .quality-panel.open {
      display: flex;
    }

    /* ── Individual quality option button ─────────────────────── */
    .q-option {
      padding: 7px 11px;
      font-size: 13px;
      text-align: left;
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 5px;
      background: rgba(255, 255, 255, 0.05);
      color: #ddd;
      cursor: pointer;
    }
    .q-option:hover {
      background: rgba(255, 255, 255, 0.12);
    }
    .q-option.active {
      border-color: #3b82f6;
      background: rgba(37, 99, 235, 0.3);
      color: #fff;
    }

    /* ── Status bar below the player ─────────────────────────── */
    .status-bar {
      margin-top: 14px;
      padding: 10px 14px;
      font-size: 13px;
      font-family: ui-monospace, monospace;
      background: #f0f0f0;
      border-radius: 7px;
      color: #333;
      line-height: 1.6;
    }
  </style>
</head>
<body>
  <h2>Custom Quality Selector</h2>

  <div class="player-shell">
    <fastpix-player
      id="player"
      playback-id="your-playback-id"
      preload="auto"
      muted
    >
      <!-- slot="top-right" places this div in the top-right corner -->
      <div slot="top-right" id="quality-slot" style="display:flex; flex-direction:column; align-items:flex-end;">

        <!-- Toggle button -->
        <button
          type="button"
          id="quality-toggle"
          class="quality-toggle"
          aria-expanded="false"
          aria-controls="quality-panel"
        >
          Quality
        </button>

        <!-- Dropdown panel — populated by JS once levels are ready -->
        <div
          id="quality-panel"
          class="quality-panel"
          role="listbox"
          aria-label="Video quality options"
        ></div>

      </div>
    </fastpix-player>
  </div>

  <!-- Live status: updates on every fastpixqualitychange event -->
  <div class="status-bar" id="status">Mode: - · Playing: - · Locked: -</div>

  <script>
    customElements.whenDefined("fastpix-player").then(() => {
      const player = document.getElementById("player");
      const toggle = document.getElementById("quality-toggle");
      const panel  = document.getElementById("quality-panel");
      const status = document.getElementById("status");

      /* ── Dropdown open/close ─────────────────────────────── */
      function openPanel()  { panel.classList.add("open"); toggle.setAttribute("aria-expanded", "true"); }
      function closePanel() { panel.classList.remove("open"); toggle.setAttribute("aria-expanded", "false"); }

      toggle.addEventListener("click", () => {
        panel.classList.contains("open") ? closePanel() : openPanel();
      });

      document.addEventListener("click", (e) => {
        if (!panel.contains(e.target) && !toggle.contains(e.target)) closePanel();
      });

      /* ── Status bar ──────────────────────────────────────── */
      function refreshStatus() {
        const q = player.getPlaybackQuality();
        const mode    = q.mode === "auto" ? "Auto (ABR)" : "Manual";
        const playing = q.loadedLevel ? q.loadedLevel.label : "-";
        const locked  = q.mode === "manual" && q.lockedLevel ? q.lockedLevel.label : "-";
        status.textContent = `Mode: ${mode} · Playing: ${playing} · Locked: ${locked}`;
      }

      /* ── Highlight active button ─────────────────────────── */
      function refreshActiveButton() {
        const q = player.getPlaybackQuality();
        panel.querySelectorAll(".q-option").forEach(btn => btn.classList.remove("active"));

        if (q.mode === "auto") {
          const autoBtn = panel.querySelector('[data-kind="auto"]');
          if (autoBtn) autoBtn.classList.add("active");
        } else if (q.lockedLevel) {
          const lockedBtn = panel.querySelector(`[data-level-id="${q.lockedLevel.id}"]`);
          if (lockedBtn) lockedBtn.classList.add("active");
        }
      }

      /* ── Build the dropdown ──────────────────────────────── */
      function buildMenu(levels) {
        panel.textContent = "";

        // Auto button — calls setQualityAuto()
        const autoBtn = document.createElement("button");
        autoBtn.type = "button";
        autoBtn.className = "q-option";
        autoBtn.dataset.kind = "auto";
        autoBtn.textContent = "Auto (ABR)";
        autoBtn.addEventListener("click", () => { player.setQualityAuto(); closePanel(); });
        panel.appendChild(autoBtn);

        // One button per rendition — calls setQualityLevel(id)
        if (levels.length === 0) {
          const note = document.createElement("span");
          note.style.cssText = "font-size:12px;color:#888;padding:4px";
          note.textContent = "No alternate renditions.";
          panel.appendChild(note);
        } else {
          levels.forEach((lvl) => {
            const btn = document.createElement("button");
            btn.type = "button";
            btn.className = "q-option";
            btn.dataset.levelId = String(lvl.id);
            btn.textContent = lvl.label + (lvl.bitrate ? ` · ${Math.round(lvl.bitrate / 1000)} kbps` : "");
            btn.addEventListener("click", () => { player.setQualityLevel(lvl.id); closePanel(); });
            panel.appendChild(btn);
          });
        }

        refreshActiveButton();
        refreshStatus();
      }

      /* ── Event: levels ready ─────────────────────────────── */
      player.addEventListener("fastpixqualitylevelsready", (e) => {
        buildMenu(e.detail.levels);
      });

      /* ── Event: quality changed ──────────────────────────── */
      player.addEventListener("fastpixqualitychange", () => {
        refreshActiveButton();
        refreshStatus();
      });

      /* ── Event: quality failed ───────────────────────────── */
      player.addEventListener("fastpixqualityfailed", (e) => {
        console.warn("[quality] failed:", e.detail.reason);
        player.setQualityAuto();
        refreshActiveButton();
        refreshStatus();
      });

      /* ── Eager fallback if levels already loaded ─────────── */
      queueMicrotask(() => {
        const levels = player.getQualityLevels();
        if (levels.length > 0) buildMenu(levels);
      });

    }); // end customElements.whenDefined
  </script>
</body>
</html>

What each section does

Code sectionAPI / eventPurpose
--resolution-selector: noneCSS variableHides the built-in quality button
slot="top-right"Slot attributePlaces the toggle + panel in the top-right corner
buildMenu(levels)getQualityLevels()Renders one button per rendition
Auto button clicksetQualityAuto()Re-enables adaptive bitrate
Rendition button clicksetQualityLevel(id)Locks to a specific resolution
refreshStatus()getPlaybackQuality()Updates the status bar
refreshActiveButton()getPlaybackQuality()Highlights the active button
fastpixqualitylevelsreadyEventTriggers menu build after manifest loads
fastpixqualitychangeEventKeeps the UI in sync
fastpixqualityfailedEventFalls back to Auto on error
queueMicrotask fallbackgetQualityLevels()Handles pre-loaded manifests

Understand the typical flow

  1. Page loads → <fastpix-player> is created
  2. Video manifest loads → fastpixqualitylevelsready triggers
  3. Your code calls getQualityLevels() and builds quality buttons
  4. Viewer clicks "720p" → your code calls setQualityLevel(id)
  5. Player switches rendition → fastpixqualitychange triggers
  6. Your code calls getPlaybackQuality() and highlights the active button
  7. Viewer clicks "Auto" → your code calls setQualityAuto()
  8. fastpixqualitychange triggers again → UI updates

Quick reference

NameTypeDescription
getQualityLevels()MethodGet all available renditions
setQualityLevel(id)MethodLock to one rendition
setQualityAuto()MethodRe-enable ABR
getPlaybackQuality()MethodRead current quality state
fastpixqualitylevelsreadyEventManifest loaded - build your menu
fastpixqualitychangeEventQuality switched - update your UI
fastpixqualityfailedEventQuality change failed - handle the error
slot="top-right" etc.HTML attributePlace custom HTML over the video
--user-slot-zCSS variableControl slot layer stacking order
--user-slot-bottom-clearanceCSS variableSpace between bottom slots and seek bar
--resolution-selectorCSS variableSet to none to hide the built-in quality button

Frequently asked questions

Do quality level IDs stay the same across different videos?

No. Each video's HLS manifest generates its own rendition ladder with unique IDs. Always call getQualityLevels() after fastpixqualitylevelsready triggers for the current video. Do not cache IDs from one video and reuse them on another.

What happens if the video has only one rendition?

getQualityLevels() returns an empty array. The player uses the single available rendition and ABR has no alternatives to switch between. Hide or disable your quality menu in this case.

Can I set a default quality lock before the video starts?

Listen for fastpixqualitylevelsready, find the rendition you want by label or height, and call setQualityLevel(id) immediately. The player applies the lock before the first segment plays at the ABR-selected level.

How do I show the currently active quality in the menu without polling?

Listen for fastpixqualitychange, it triggers on every ABR switch and every manual selection. Call getPlaybackQuality() inside the handler and update your UI. No polling or timers are needed.


What's next?