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
- Before you begin
- Step 1 — Set up the player and container
- Step 2 — Configure attributes (optional)
- Step 3 — Add placeholder containers for your track buttons
- Step 3b — Optional: Forward / Backward seek buttons
- Step 4 — Wait for tracks, then render audio buttons
- Step 5 — Poll for subtitle tracks
- Step 6 — Render subtitle buttons with an Off option
- Step 7 — Keep buttons in sync after track changes
- Step 8 — Render a custom subtitle overlay
- Step 9 — Persist user preferences
- Putting it all together
- TrackInfo reference
- Best practices
- Full API reference
- 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
fastpixsubtitlecueevent (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 firingfastpixsubtitlechange. Users (or your code) can still turn subtitles on later via the built-in menu orsetSubtitleTrack(...).hide-native-subtitles— Keep the player’s internal subtitle overlay visually empty while still emittingfastpixsubtitlecueand 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 includesubtitleTracks, but it reflects the state at the moment the event fired. If that was before subtitle tracks were registered, the array will be empty. PollinggetSubtitleTracks()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,
fastpixsubtitlecuemay 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'sonclickprevents 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:
| 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.
// 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.
Updated about 2 hours ago