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.

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

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 and Add own subtitle tracks.

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.

<script src="https://cdn.jsdelivr.net/npm/@fastpix/[email protected]" 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>
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):

<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 and in the 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.

<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>
.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:

<div class="player-time">
  <button id="forwardButton">Forward 20s</button>
  <button id="backwardButton">Backward 20s</button>
</div>
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:

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 for a full description of all fields.

// 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:

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:

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.

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.

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:

FieldTypeDescription
textstringThe cue text to display. Empty string when the cue ends.
languagestringLanguage code of the active subtitle track (e.g. "en")
startTimenumberCue start time in seconds
endTimenumberCue 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.

// 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.

// 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):

<!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/[email protected]" 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:

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