> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://fastpix.com/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://fastpix.com/docs/_mcp/server.

# Build a custom track switcher UI

> Build a fully custom audio and subtitle track switcher UI using the FastPix Player JavaScript API, with cue rendering, preference persistence, and real-time sync.

Build a fully custom audio and subtitle track switcher UI using the FastPix Player JavaScript API, with cue rendering, preference persistence, and real-time sync.

The built-in subtitle and audio menus handle the default use case well. But if you're building your own player controls — or need subtitle rendering you can fully style — FastPix Player exposes a complete JavaScript API for reading tracks, switching between them, and reacting to changes in real time.

This guide walks through the full demo implementation, step by step.

---

## Table of contents

* [What you'll build](#what-youll-build)
* [Before you begin](#before-you-begin)
* [Step 1 — Set up the player and container](#step-1--set-up-the-player-and-container)
* [Step 2 — Configure attributes (optional)](#step-2--configure-attributes-optional)
* [Step 3 — Add placeholder containers for your track buttons](#step-3--add-placeholder-containers-for-your-track-buttons)
* [Step 3b — Optional: Forward / Backward seek buttons](#step-3b--optional-forward--backward-seek-buttons)
* [Step 4 — Wait for tracks, then render audio buttons](#step-4--wait-for-tracks-then-render-audio-buttons)
* [Step 5 — Poll for subtitle tracks](#step-5--poll-for-subtitle-tracks)
* [Step 6 — Render subtitle buttons with an Off option](#step-6--render-subtitle-buttons-with-an-off-option)
* [Step 7 — Keep buttons in sync after track changes](#step-7--keep-buttons-in-sync-after-track-changes)
* [Step 8 — Render a custom subtitle overlay](#step-8--render-a-custom-subtitle-overlay)
* [Step 9 — Persist user preferences](#step-9--persist-user-preferences)
* [Putting it all together](#putting-it-all-together)
* [TrackInfo reference](#trackinfo-reference)
* [Best practices](#best-practices)
* [Full API reference](#full-api-reference)
* [See also: React integration](#see-also-react-integration)

---

## What you'll build

By the end of this guide you'll have:

* A player that starts with a specific audio and subtitle track set by default
* The player's own subtitle rendering suppressed so your overlay is the only one visible
* Custom audio track buttons that update to reflect the active track
* Custom subtitle track buttons, including an Off option
* **Subtitle display logic** — a styled subtitle overlay driven by the **`fastpixsubtitlecue`** event (you listen for the event and set the overlay's text; when subtitles are Off or the cue ends, you clear or hide it)
* UI that stays in sync as tracks change
* User language preferences persisted across sessions

---

## Before you begin

You'll need a FastPix stream URL that already has multiple audio or subtitle tracks. If your stream doesn't have them yet and you'd like to add some, see [Add own audio tracks](/docs/video-on-demand/add-audio-to-a-video) and [Add own subtitle tracks](/docs/video-on-demand/add-subtitles-to-a-video).

The examples on this page use the CDN-hosted player script. If you've installed the player via npm, replace the `<script>` tag with your local build path.

---

## Step 1 — Set up the player and container

Start with a `<fastpix-player>` wrapped in a positioned container. The container is required if you plan to overlay a custom subtitle div — `position: relative` on the wrapper is what makes absolute positioning of the subtitle layer work correctly.

```html
<script src="https://cdn.jsdelivr.net/npm/@fastpix/fp-player@1.0.15" defer></script>

<div class="player-container">
  <fastpix-player
    playback-id="your-playback-id-with-tracks"
    loop
    auto-play
    muted
    preload="auto"
  ></fastpix-player>
  <div class="custom-subtitle" data-role="custom-subtitle"></div>
</div>
```

```css
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  max-width: 800px;
  margin: 20px auto;
  padding: 0 20px;
}
.player-container {
  position: relative;
  width: 100%;
  aspect-ratio: 16/9;
}

fastpix-player {
  width: 100%;
  --aspect-ratio: 21/9;
}

/* Subtitle overlay: dark pill/bubble, centered over bottom of video */
.custom-subtitle {
  position: absolute;
  left: 50%;
  bottom: 10%;
  transform: translateX(-50%);
  max-width: 90%;
  padding: 10px 20px;
  background: rgba(40, 40, 40, 0.9);
  color: #fff;
  display: none;
  border-radius: 9999px; /* pill shape */
  text-align: center;
  font-size: 15px;
  line-height: 1.4;
  pointer-events: none;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
```

The `data-role="custom-subtitle"` attribute is used later to locate this overlay relative to its specific player — this is what makes the pattern work cleanly when you have multiple players on the same page. In **Step 8** you'll add the **subtitle display logic**: listen for the **`fastpixsubtitlecue`** event and update this element's `textContent` (and show/hide it) so the current cue text appears in your overlay.

---

## Step 2 — Configure attributes (optional)

You can optionally set **default tracks** and **subtitle rendering behavior** with HTML attributes on `<fastpix-player>`:

* **`default-audio-track`** — Start with a specific audio track by label (case-insensitive).
* **`default-subtitle-track`** — Start with a specific subtitle track by label (case-insensitive).
* **`disable-hidden-captions`** — Start with all subtitles/captions **Off** on load without firing `fastpixsubtitlechange`. Users (or your code) can still turn subtitles on later via the built-in menu or `setSubtitleTrack(...)`.
* **`hide-native-subtitles`** — Keep the player’s internal subtitle overlay visually empty while still emitting `fastpixsubtitlecue` and track events. Use this when you render subtitles yourself so you never see duplicate text.

Example — add them on the same `<fastpix-player>` as in Step 1 (replace labels with your manifest’s track names):

```html
<fastpix-player
  playback-id="your-playback-id"
  loop
  auto-play
  muted
  preload="auto"
  default-audio-track="French"
  default-subtitle-track="English"
  disable-hidden-captions
  hide-native-subtitles
></fastpix-player>
```

Full behavior and examples for these attributes are in the [Full API reference](#full-api-reference) and in the [TrackInfo reference](#trackinfo-reference) on this page. For the rest of this guide we'll assume you add them to the player as needed.

---

## Step 3 — Add placeholder containers for your track buttons

Below the player, add the HTML containers that will hold the rendered track buttons. Show a loading state until tracks are ready.

```html
<div class="track-controls">
  <h3>Audio Tracks</h3>
  <div id="audioButtons" class="track-buttons">
    <em>Loading tracks...</em>
  </div>
  <div id="currentAudio" class="current-track"></div>
</div>

<div class="track-controls">
  <h3>Subtitle Tracks</h3>
  <div id="subtitleButtons" class="track-buttons">
    <em>Loading tracks...</em>
  </div>
  <div id="currentSubtitle" class="current-track"></div>
</div>
```

```css
.track-controls {
  margin-top: 20px;
  padding: 15px;
  background: #f5f5f5;
  border-radius: 8px;
}

.track-controls h3 {
  margin: 0 0 10px 0;
  font-weight: 700;
  font-family: Georgia, 'Times New Roman', serif;
  color: #000;
}

.track-buttons {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.track-btn {
  padding: 8px 16px;
  border: 1px solid #ddd;
  border-radius: 6px;
  background: white;
  cursor: pointer;
  font-size: 14px;
}

.track-btn:hover {
  border-color: #007bff;
}

.track-btn.active {
  background: #007bff;
  color: white;
  border-color: #007bff;
}

.current-track {
  margin-top: 10px;
  font-size: 13px;
  color: #666;
}

.player-time {
  margin-top: 12px;
}
```

---

## Step 3b — Optional: Forward / Backward seek buttons

The demo also includes simple skip-ahead / skip-back controls using the player’s **`seekForward(seconds)`** and **`seekBackward(seconds)`** methods. Add a small control group and wire it in script:

```html
<div class="player-time">
  <button id="forwardButton">Forward 20s</button>
  <button id="backwardButton">Backward 20s</button>
</div>
```

```js
const forwardButton = document.getElementById('forwardButton');
const backwardButton = document.getElementById('backwardButton');

forwardButton.addEventListener('click', () => {
  player.seekForward(10);
});
backwardButton.addEventListener('click', () => {
  player.seekBackward(10);
});
```

Seconds are clamped to the media range. You can use the same pattern with any skip value (e.g. 5 or 30).

---

## Step 4 — Wait for tracks, then render audio buttons

Tracks are discovered asynchronously after the HLS manifest is parsed. The `fastpixtracksready` event is your signal that tracks are available. Always wait for this event before calling any track methods.

Get the player reference, wire up the event, and call `getAudioTracks()` to retrieve the current audio snapshot:

```js
const player                = document.querySelector('fastpix-player');
const audioButtonsContainer = document.getElementById('audioButtons');
const currentAudioDisplay   = document.getElementById('currentAudio');

player.addEventListener('fastpixtracksready', () => {
  const audioTracks = player.getAudioTracks();
  renderAudioButtons(audioTracks);

  // Subtitle tracks need separate handling — see Step 5
});
```

`getAudioTracks()` returns an array of `TrackInfo` objects. The `isCurrent` flag tells you which track is active so you can set the initial selected state in your UI. See the [TrackInfo reference](#trackinfo-reference) for a full description of all fields.

```js
// Example return value from getAudioTracks()
[
  { id: 0, label: "English", language: "en", isDefault: false, isCurrent: false },
  { id: 1, label: "French",  language: "fr", isDefault: false, isCurrent: true  },
  { id: 2, label: "Hindi",   language: "hi", isDefault: false, isCurrent: false }
]
```

Now write `renderAudioButtons` to turn that array into buttons:

```js
function renderAudioButtons(tracks) {
  if (!tracks || tracks.length === 0) {
    audioButtonsContainer.innerHTML = '<em>No audio tracks available</em>';
    currentAudioDisplay.textContent = '';
    return;
  }

  audioButtonsContainer.innerHTML = '';

  tracks.forEach((track) => {
    const btn = document.createElement('button');
    btn.className = 'track-btn' + (track.isCurrent ? ' active' : '');
    btn.textContent = `${track.label} (${track.language || 'unknown'})`;
    btn.onclick = () => {
      // Public API is label-driven (no numeric ids / language codes)
      player.setAudioTrack(track.label);
    };
    audioButtonsContainer.appendChild(btn);
  });

  const current = tracks.find((t) => t.isCurrent);
  currentAudioDisplay.textContent = current
    ? `Current: ${current.label} (${current.language || 'unknown'})`
    : '';
}
```

Note that `setAudioTrack` takes a **label string** — not a numeric `id`. The `id` field is an internal index and must never be used for switching or persistence.

---

## Step 5 — Poll for subtitle tracks

Subtitle `textTracks` attach to the player slightly later than audio tracks — after `MANIFEST_PARSED` fires but before the player finishes registering them. This means `fastpixtracksready` can fire before subtitle tracks are ready, and calling `getSubtitleTracks()` immediately in the handler can return an empty array.

The reliable pattern is to poll `getSubtitleTracks()` for a short window after `fastpixtracksready` fires, and render as soon as the array becomes non-empty:

```js
const subtitleButtonsContainer = document.getElementById('subtitleButtons');
const currentSubtitleDisplay   = document.getElementById('currentSubtitle');

let subtitlePollId = null;

player.addEventListener('fastpixtracksready', (e) => {
  const audioTracks = player.getAudioTracks();
  renderAudioButtons(audioTracks);

  if (subtitlePollId) clearInterval(subtitlePollId);
  const startTime = Date.now();

  subtitlePollId = setInterval(() => {
    const subtitleTracks = player.getSubtitleTracks();
    if (subtitleTracks && subtitleTracks.length > 0) {
      clearInterval(subtitlePollId);
      subtitlePollId = null;
      renderSubtitleButtons(subtitleTracks);
    } else if (Date.now() - startTime > 10000) { // safety timeout 10s
      clearInterval(subtitlePollId);
      subtitlePollId = null;
    }
  }, 500);
});
```

> **Why not just use `e.detail.subtitleTracks`?** The event detail does include `subtitleTracks`, but it reflects the state at the moment the event fired. If that was before subtitle tracks were registered, the array will be empty. Polling `getSubtitleTracks()` directly catches the second registration.

---

## Step 6 — Render subtitle buttons with an Off option

Subtitle buttons work the same as audio buttons, with one addition: always render an Off button first. Off is the state where no track has `isCurrent: true` — which happens after `setSubtitleTrack(null)` or `disableSubtitles()` is called.

```js
function renderSubtitleButtons(tracks) {
  subtitleButtonsContainer.innerHTML = '';

  // Add "Off" button — active when no subtitle track has isCurrent: true
  const offBtn = document.createElement('button');
  offBtn.className = 'track-btn' + (!tracks.some((t) => t.isCurrent) ? ' active' : '');
  offBtn.textContent = 'Off';
  offBtn.onclick = () => {
    if (typeof player.disableSubtitles === 'function') {
      player.disableSubtitles();
    } else {
      player.setSubtitleTrack(null);
    }
    // Also immediately clear any currently displayed custom subtitle text
    const customSubtitleDiv = getCustomSubtitleDiv(player);
    if (customSubtitleDiv) {
      customSubtitleDiv.textContent = '';
      customSubtitleDiv.style.display = 'none';
    }
    currentSubtitleDisplay.textContent = 'Current: Off';
  };
  subtitleButtonsContainer.appendChild(offBtn);

  if (!tracks || tracks.length === 0) {
    currentSubtitleDisplay.textContent = 'No subtitle tracks available';
    return;
  }

  tracks.forEach((track) => {
    const btn = document.createElement('button');
    btn.className = 'track-btn' + (track.isCurrent ? ' active' : '');
    btn.textContent = `${track.label} (${track.language || 'unknown'})`;
    btn.onclick = () => {
      // Public API is label-driven (no numeric ids / language codes)
      player.setSubtitleTrack(track.label);
    };
    subtitleButtonsContainer.appendChild(btn);
  });

  const current = tracks.find((t) => t.isCurrent);
  currentSubtitleDisplay.textContent = current
    ? `Current: ${current.label} (${current.language || 'unknown'})`
    : 'Current: Off';
}
```

**`disableSubtitles()` vs `setSubtitleTrack(null)`:** Both turn subtitles off and produce the same result. `disableSubtitles()` is a named convenience method added in newer player builds. The feature-detect guard (`typeof player.disableSubtitles === 'function'`) ensures the code works across all build versions — `setSubtitleTrack(null)` is the stable fallback.

> **Clear the overlay immediately on Off.** When the user turns subtitles off, `fastpixsubtitlecue` may not fire again for several seconds — not until the next cue boundary. If you only clear the overlay in the cue handler, the last cue text stays visible on screen after the user has already turned subtitles off. Clearing it directly in the Off button's `onclick` prevents this.

---

## Step 7 — Keep buttons in sync after track changes

`fastpixaudiochange` and `fastpixsubtitlechange` fire whenever a track switch happens — whether the user clicked one of your buttons, a programmatic call was made, or the built-in player menu was used. Re-render buttons in these handlers so the active state always reflects reality.

```js
player.addEventListener('fastpixaudiochange', (e) => {
  const { tracks, currentTrack } = e.detail || {};
  const resolvedCurrent = currentTrack || (Array.isArray(tracks) ? tracks.find(t => t.isCurrent) : null);
  renderAudioButtons(tracks || player.getAudioTracks());
});

player.addEventListener('fastpixsubtitlechange', (e) => {
  const { tracks, currentTrack } = e.detail || {};
  const resolvedCurrent = currentTrack || (Array.isArray(tracks) ? tracks.find(t => t.isCurrent) : null);
  renderSubtitleButtons(tracks || player.getSubtitleTracks());
});
```

Both events also carry `currentTrack` (a full `TrackInfo` object, or `null` for Off) and `currentId` if you need them for logging or analytics.

---

## Step 8 — Render a custom subtitle overlay (subtitle display logic)

This step is the **subtitle display logic**: you listen for the **`fastpixsubtitlecue`** event and use it to show the current cue text in your custom overlay div (from Step 1), or clear/hide the overlay when subtitles are Off or the cue ends.

With **`hide-native-subtitles`** on the player, the player’s internal subtitle container never paints text (so the built-in overlay stays hidden). Cue data is still available: **`fastpixsubtitlecue`** still fires and you’re responsible for displaying that text in your own overlay.

The event fires each time the active cue changes. Its `detail` object carries:

| Field       | Type     | Description                                              |
| ----------- | -------- | -------------------------------------------------------- |
| `text`      | `string` | The cue text to display. Empty string when the cue ends. |
| `language`  | `string` | Language code of the active subtitle track (e.g. `"en"`) |
| `startTime` | `number` | Cue start time in seconds                                |
| `endTime`   | `number` | Cue end time in seconds                                  |

For most overlays you'll only need `text`. The `startTime` and `endTime` fields are useful if you want timed animations, karaoke-style highlighting, or precise cue scheduling.

```js
// Helper: locate the custom subtitle div scoped to a given player's container
function getCustomSubtitleDiv(forPlayer) {
  const container = forPlayer.closest('.player-container');
  return container ? container.querySelector('[data-role="custom-subtitle"]') : null;
}

// Custom subtitle overlay: attach to ALL players so each renders separately.
document.querySelectorAll('fastpix-player').forEach((p) => {
  p.addEventListener('fastpixsubtitlecue', (e) => {
    const { text, language, startTime, endTime } = /** @type {CustomEvent} */ (e).detail;

    // Only show cues when a subtitle track is actually enabled (not Off).
    const subtitleTracks = p.getSubtitleTracks();
    const hasActiveSubtitle = Array.isArray(subtitleTracks) && subtitleTracks.some(t => t.isCurrent);

    const customSubtitleDiv = getCustomSubtitleDiv(p);
    if (customSubtitleDiv) {
      if (hasActiveSubtitle && text) {
        customSubtitleDiv.textContent = text;
        customSubtitleDiv.style.display = 'block';
      } else {
        customSubtitleDiv.textContent = '';
        customSubtitleDiv.style.display = 'none';
      }
    }
  });
});
```

Using `document.querySelectorAll('fastpix-player')` and `p.closest('.player-container')` rather than referencing a single player and overlay by id is intentional — this exact code works unchanged whether you have one player on the page or ten, because each cue listener finds its own scoped overlay through the DOM.

Because `hide-native-subtitles` keeps the player's internal subtitle layer from painting, your overlay is the only thing rendering subtitles. Style it however you want — font, position, background, animation — without any risk of conflicting with the player's own styles.

---

## Step 9 — Persist user preferences

Store the user's preferred language so it's automatically applied the next time they load a video. Use the `language` code (e.g. `"fr"`) for storage — it's stable across videos. Use the `label` when calling `setAudioTrack`, since that's what the selection API accepts.

**Note:** Restore **audio** preference in `fastpixtracksready` (audio tracks are ready then). Restore **subtitle** preference when subtitle tracks first become available — e.g. in the same place you first call `renderSubtitleButtons(subtitleTracks)` (e.g. inside the Step 5 poll callback when `getSubtitleTracks()` returns a non-empty array). If you restore subtitles in `fastpixtracksready` only, `getSubtitleTracks()` may still be empty and the saved preference won't apply.

```js
// On load: restore audio preference (audio tracks are ready at fastpixtracksready)
player.addEventListener('fastpixtracksready', () => {
  const audioTracks = player.getAudioTracks();
  const savedAudioLang = localStorage.getItem('fp_audioLang');

  if (savedAudioLang) {
    const match = audioTracks.find((t) => t.language === savedAudioLang);
    if (match) player.setAudioTrack(match.label);
  }
});

// Restore subtitle preference when subtitle tracks first become available (e.g. in your poll callback)
function restoreSubtitlePreference() {
  const subtitleTracks = player.getSubtitleTracks();
  const savedSubtitleLang = localStorage.getItem('fp_subtitleLang');
  if (savedSubtitleLang && subtitleTracks && subtitleTracks.length > 0) {
    const match = subtitleTracks.find((t) => t.language === savedSubtitleLang);
    if (match) player.setSubtitleTrack(match.label);
  }
}
// Call restoreSubtitlePreference() when you first get subtitle tracks (e.g. in the Step 5 poll when subs appear).

// On change: save updated preference
player.addEventListener('fastpixaudiochange', (e) => {
  const t = e.detail?.currentTrack;
  if (t?.language) localStorage.setItem('fp_audioLang', t.language);
});

player.addEventListener('fastpixsubtitlechange', (e) => {
  const t = e.detail?.currentTrack;
  if (t?.language) {
    localStorage.setItem('fp_subtitleLang', t.language);
  } else {
    // User turned subtitles off — clear the saved preference
    localStorage.removeItem('fp_subtitleLang');
  }
});
```

---

## Putting it all together

Here's the complete implementation with every step assembled (matches `demo/audio_subtitle_tracks.html`; the "Current:" lines use label + language as in this guide, not the numeric id):

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Audio Track Switching Demo</title>
   
    <script src="https://cdn.jsdelivr.net/npm/@fastpix/fp-player@1.0.15" defer></script>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            max-width: 800px;
            margin: 20px auto;
            padding: 0 20px;
        }
        fastpix-player {
            width: 100%;
            --aspect-ratio: 21/9;
        }
        .player-container {
            position: relative;
            width: 100%;
            aspect-ratio: 16/9;
        }
        /* Subtitle overlay: dark pill/bubble, centered over bottom of video */
        .custom-subtitle {
            position: absolute;
            left: 50%;
            bottom: 10%;
            transform: translateX(-50%);
            max-width: 90%;
            padding: 10px 20px;
            background: rgba(40, 40, 40, 0.9);
            color: #fff;
            display: none;
            border-radius: 9999px;
            text-align: center;
            font-size: 15px;
            line-height: 1.4;
            pointer-events: none;
            box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
        }
        .track-controls {
            margin-top: 20px;
            padding: 15px;
            background: #f5f5f5;
            border-radius: 8px;
        }
        .track-controls h3 {
            margin: 0 0 10px 0;
            font-weight: 700;
            font-family: Georgia, 'Times New Roman', serif;
            color: #000;
        }
        .track-buttons {
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
        }
        .track-btn {
            padding: 8px 16px;
            border: 1px solid #ddd;
            border-radius: 6px;
            background: white;
            cursor: pointer;
            font-size: 14px;
        }
        .track-btn:hover {
            border-color: #007bff;
        }
        .track-btn.active {
            background: #007bff;
            color: white;
            border-color: #007bff;
        }
        .current-track {
            margin-top: 10px;
            font-size: 13px;
            color: #666;
        }
        .player-time {
            margin-top: 12px;
        }
    </style>
</head>
<body>
    <h1>Audio Track Switching Demo</h1>
    
    <div class="player-container">
        <fastpix-player 
            playback-id="your-playback-id-with-tracks" 
            loop 
            auto-play 
            muted
            preload="auto"
            default-audio-track="French"
            default-subtitle-track="English"
            hide-native-subtitles
            >
        </fastpix-player>
        <div class="custom-subtitle" data-role="custom-subtitle"></div>
    </div>

    <div class="player-time">
        
        <button id="forwardButton">Forward 20s</button>
        <button id="backwardButton">Backward 20s</button>
        
    </div>

    <div class="track-controls">
        <h3>Audio Tracks</h3>
        <div id="audioButtons" class="track-buttons">
            <em>Loading tracks...</em>
        </div>
        <div id="currentAudio" class="current-track"></div>
    </div>

    <div class="track-controls">
        <h3>Subtitle Tracks</h3>
        <div id="subtitleButtons" class="track-buttons">
            <em>Loading tracks...</em>
        </div>
        <div id="currentSubtitle" class="current-track"></div>
    </div>

    <script>
        // Controls below target the first player on the page,
        // but custom subtitles are scoped per-player container.
        const player = document.querySelector('fastpix-player');
        const audioButtonsContainer = document.getElementById('audioButtons');
        const subtitleButtonsContainer = document.getElementById('subtitleButtons');
        const currentAudioDisplay = document.getElementById('currentAudio');
        const currentSubtitleDisplay = document.getElementById('currentSubtitle');
        const forwardButton = document.getElementById('forwardButton');
        const backwardButton = document.getElementById('backwardButton');

        forwardButton.addEventListener('click', () => {
            player.seekForward(10);
        });
        backwardButton.addEventListener('click', () => {
            player.seekBackward(10);
        });
        
        function getCustomSubtitleDiv(forPlayer) {
            const container = forPlayer.closest('.player-container');
            return container ? container.querySelector('[data-role="custom-subtitle"]') : null;
        }
        let subtitlePollId = null;

        // Wait for tracks to be ready
        player.addEventListener('fastpixtracksready', (e) => {
            console.log("Tracks ready!", e.detail);
            
            // Get audio tracks
            const audioTracks = player.getAudioTracks();
            console.log("Audio tracks:", audioTracks);
            
            // Render audio track buttons
            renderAudioButtons(audioTracks);

            // Subtitle tracks often become available slightly after MANIFEST_PARSED.
            // Poll getSubtitleTracks() for a short window so we render
            // all detected subtitle tracks automatically, without needing to click Off.
            if (subtitlePollId) {
                clearInterval(subtitlePollId);
            }
            const startTime = Date.now();
            subtitlePollId = setInterval(() => {
                const subtitleTracks = player.getSubtitleTracks();
                console.log("Polled subtitle tracks:", subtitleTracks);
                if (subtitleTracks && subtitleTracks.length > 0) {
                    clearInterval(subtitlePollId);
                    subtitlePollId = null;
                    // Render and highlight whichever subtitle track is currently "showing"
                    // so that the actively playing subtitle is highlighted by default.
                    renderSubtitleButtons(subtitleTracks);
                } else if (Date.now() - startTime > 10000) { // safety timeout 10s
                    clearInterval(subtitlePollId);
                    subtitlePollId = null;
                }
            }, 500);
        });

        // Custom subtitle overlay: attach to ALL players so each renders separately.
        document.querySelectorAll('fastpix-player').forEach((p) => {
            p.addEventListener('fastpixsubtitlecue', (e) => {
                const { text, language, startTime, endTime } = /** @type {CustomEvent} */ (e).detail;
                console.log('Current subtitle cue:', text, language, startTime, endTime);

                // Only show cues when a subtitle track is actually enabled (not Off).
                const subtitleTracks = p.getSubtitleTracks();
                const hasActiveSubtitle = Array.isArray(subtitleTracks) && subtitleTracks.some(t => t.isCurrent);

                const customSubtitleDiv = getCustomSubtitleDiv(p);
                if (customSubtitleDiv) {
                    if (hasActiveSubtitle && text) {
                        customSubtitleDiv.textContent = text;
                        customSubtitleDiv.style.display = 'block';
                    } else {
                        customSubtitleDiv.textContent = '';
                        customSubtitleDiv.style.display = 'none';
                    }
                }
            });
        });

        // Listen for audio track changes
        player.addEventListener('fastpixaudiochange', (e) => {
            const { tracks, currentTrack } = /** @type {CustomEvent} */ (e).detail || {};
            const resolvedCurrent = currentTrack || (Array.isArray(tracks) ? tracks.find(t => t.isCurrent) : null);
            console.log("Audio changed. Current track:", resolvedCurrent);
            renderAudioButtons(tracks || player.getAudioTracks());
        });

        // Listen for subtitle track changes
        player.addEventListener('fastpixsubtitlechange', (e) => {
            const { tracks, currentTrack } = /** @type {CustomEvent} */ (e).detail || {};
            const resolvedCurrent = currentTrack || (Array.isArray(tracks) ? tracks.find(t => t.isCurrent) : null);
            console.log("Subtitle changed. Current track:", resolvedCurrent);
            renderSubtitleButtons(tracks || player.getSubtitleTracks());
        });

        function renderAudioButtons(tracks) {
            if (!tracks || tracks.length === 0) {
                audioButtonsContainer.innerHTML = '<em>No audio tracks available</em>';
                currentAudioDisplay.textContent = '';
                return;
            }

            audioButtonsContainer.innerHTML = '';
            
            tracks.forEach(track => {
                const btn = document.createElement('button');
                btn.className = 'track-btn' + (track.isCurrent ? ' active' : '');
                btn.textContent = `${track.label} (${track.language || 'unknown'})`;
                btn.onclick = () => {
                    console.log(`Switching to audio track: ${track.label} (${track.language || 'unknown'})`);
                    // Public API is label-driven (no numeric ids / language codes)
                    player.setAudioTrack(track.label);
                };
                audioButtonsContainer.appendChild(btn);
            });

            const current = tracks.find(t => t.isCurrent);
            currentAudioDisplay.textContent = current 
                ? `Current: ${current.label} (${current.language || 'unknown'})` 
                : '';
        }

        function renderSubtitleButtons(tracks) {
            subtitleButtonsContainer.innerHTML = '';
            
            // Add "Off" button
            const offBtn = document.createElement('button');
            offBtn.className = 'track-btn' + (!tracks.some(t => t.isCurrent) ? ' active' : '');
            offBtn.textContent = 'Off';
            offBtn.onclick = () => {
                console.log('Turning subtitles off');
                // Prefer the new public API if available
                if (typeof player.disableSubtitles === 'function') {
                    player.disableSubtitles();
                    console.log('Subtitles disabled');
                } else {
                    // Fallback for older builds
                    player.setSubtitleTrack(null);
                    console.log('Subtitles disabled (fallback)');
                }
                // Also immediately clear any currently displayed custom subtitle text
                const customSubtitleDiv = getCustomSubtitleDiv(player);
                if (customSubtitleDiv) {
                    customSubtitleDiv.textContent = '';
                    customSubtitleDiv.style.display = 'none';
                }
                // Update status text
                currentSubtitleDisplay.textContent = 'Current: Off';
            };
            subtitleButtonsContainer.appendChild(offBtn);

            if (!tracks || tracks.length === 0) {
                currentSubtitleDisplay.textContent = 'No subtitle tracks available';
                return;
            }
            
            tracks.forEach(track => {
                const btn = document.createElement('button');
                btn.className = 'track-btn' + (track.isCurrent ? ' active' : '');
                btn.textContent = `${track.label} (${track.language || 'unknown'})`;
                btn.onclick = () => {
                    console.log(`Switching to subtitle track: ${track.label} (${track.language || 'unknown'})`);
                    // Public API is label-driven (no numeric ids / language codes)
                    player.setSubtitleTrack(track.label);
                };
                subtitleButtonsContainer.appendChild(btn);
            });

            const current = tracks.find(t => t.isCurrent);
            currentSubtitleDisplay.textContent = current 
                ? `Current: ${current.label} (${current.language || 'unknown'})` 
                : 'Current: Off';
        }
    </script>
</body>
</html>
```

---

## TrackInfo reference

Every track method and event uses a `TrackInfo` object to represent a single track:

```typescript
type TrackInfo = {
  id:        number;   // Internal numeric index — do NOT use for switching or persistence
  label:     string;   // Display name used for switching, e.g. "English", "French"
  language?: string;   // BCP 47 code, e.g. "en", "fr", "hi" — use this for persistence
  isDefault: boolean;  // Whether this track is marked as default in the HLS manifest
  isCurrent: boolean;  // Whether this track is currently active
};
```

**Key rules:**

* Switch tracks using `label` — e.g. `setAudioTrack('French')`
* Persist user preferences using `language` — e.g. `localStorage.setItem('fp_audioLang', 'fr')`
* Never store or switch using the numeric `id` — it is internal and can differ between videos

---

## Best practices

**Track lists vary per asset.** Don't assume all videos expose the same set of audio or subtitle tracks. Check `tracks.length` before rendering and hide controls that don't apply — for example, hide the audio selector entirely if only one audio track is available.

**`fastpixtracksready` can fire more than once.** Treat every emission as "tracks snapshot updated" and re-render idempotently. Clearing containers with `innerHTML = ''` before each render is enough.

**Clear the custom subtitle overlay immediately on Off.** `fastpixsubtitlecue` may not fire again for several seconds after subtitles are turned off. Don't rely on the cue handler to clear it — clear the overlay directly in your Off button handler.

**Use the `playing` event to clear the overlay after a source change.** If the player source changes mid-session, any stale subtitle text in the overlay should be cleared. The player forwards the video element's `playing` event, so listen for `playing` on the `<fastpix-player>` and reset the overlay when it fires.

**Clean up event listeners when components unmount.** In React, Vue, or any single-page app, ghost listeners accumulate across navigations if you don't remove them. Use named handler functions and pair every `addEventListener` with a corresponding `removeEventListener` in your cleanup or `beforeUnmount` hook.

---

## Full API reference

This guide covers the integration patterns and the demo implementation. For the complete reference — all methods, properties, events, attributes, and additional usage examples — see the [Audio & Subtitle Tracks API reference](https://github.com/FastPix/web-player-component/blob/main/AUDIO_SUBTITLE_TRACKS_API.md) in the FastPix web player repository.

---

## See also: React integration

To see how this track switcher and custom subtitle overlay are integrated in a React app (e.g. a vertical shorts feed with track menu and subtitle pill), go through the **[FastPix React Shorts Demo](https://github.com/FastPix/fastpix-web-player-react-shorts-demo)**. That repo uses the same APIs (`getAudioTracks`, `setAudioTrack`, `getSubtitleTracks`, `setSubtitleTrack`, `fastpixtracksready`, `fastpixsubtitlecue`, etc.), mounts the player with `document.createElement('fastpix-player')` in `useEffect`, and shows React patterns for refs, cleanup, and feed-level state. Clone it, run `npm install` and `npm run dev`, then replace the feed playback IDs with your own multi-track assets.