Amazon Vinyl logo Amazon Vinyl v3.2.1

Usage Guide

Installation

npm install @amazon/vinyl

Creating a Player

To create a new player, call createVinylPlayer with desired configuration. Refer to the API Documentation for configuration options.

import { createVinylPlayer } from '@amazon/vinyl'

createVinylPlayer({ media: new Audio() })

Loading Tracks

Tracks are constructed as-needed from load configuration objects.

For DRM configuration and encrypted content, see DRM Configuration.

Tracks are constructed when they are prefetched, then disposed when they are no longer in the queue and the cache evicts tracks least recently used.

Basic example:

import { createVinylPlayer } from '@amazon/vinyl'

const media = new Audio()
media.controls = true
document.body.appendChild(media)

const player = createVinylPlayer({ media })
player.load(
    {
        type: 'src',
        uri: 'https://example.com/myTrack.mp3',
    },
    {
        type: 'dash',
        uri: 'https://example.com/myNextTrack.mpd',
    },
    {
        type: 'hls',
        uri: 'https://example.com/myTrack.m3u8',
    }
)

HLS Track Configuration

HLS tracks use the 'hls' type and play fMP4 HLS streams via Media Source Extensions. This works on all browsers that support MSE (Chrome, Firefox, Edge, Safari 17+). The uri should point to an HLS main playlist (.m3u8).

player.load({
    type: 'hls',
    uri: 'https://example.com/main.m3u8',
})

Media playlists are fetched lazily — only the variant selected by ABR is fetched, and results are cached. For video content with separate audio renditions, the audio rendition playlist is resolved automatically.

Because HLS tracks use the same MSE pipeline as Dash, they benefit from all of Amazon Vinyl's streaming features: adaptive bitrate switching, track preloading and prefetching, quality filtering, buffering control, and network prioritization.

A custom manifest provider can be supplied to override the default fetch behavior:

player.load({
    type: 'hls',
    uri: 'unique_identifier',
    manifestProvider: async (abort) => {
        const baseUrl = 'https://example.com/main.m3u8'
        const response = await requestWithRetry(baseUrl, undefined, { abort })
        const text = await response.text()
        const mainPlaylist = parseMainPlaylist(text)
        return {
            mainPlaylist,
            baseUrl,
            getMediaPlaylist: memoize(
                async (uri) => {
                    const resp = await requestWithRetry(new URL(uri, baseUrl))
                    return parseMediaPlaylist(await resp.text())
                },
                (uri) => uri
            ),
        }
    },
})

Note: HLS via MSE supports both fMP4 streams (those with #EXT-X-MAP) and MPEG-TS streams, which are transmuxed to fMP4 on the fly. MPEG-TS via MSE does not support encrypted content — for encrypted MPEG-TS, use native HLS playback on Safari and iOS by loading the stream with the 'src' track type.

Dash Track Configuration

Dash track default behavior is to treat the uri as the Dash MPD location. This can be overridden with a custom manifest provider, which is returns a parsed dash manifest.

DASH streams may use ISO-BMFF (MP4/fMP4) or WebM containers, with SegmentTemplate, SegmentList, or SegmentBase addressing. SegmentBase streams are indexed from their ISO-BMFF sidx box or WebM Cues element as appropriate. Codec support (e.g. AVC/HEVC/VP9 video, AAC/Opus/FLAC audio) is gated by the browser's Media Source Extensions support.

player.load({
    type: 'dash',
    uri: 'unique_identifier',
    manifestProvider: async () => {
        const response = await requestWithRetry('https://example.com/service')
        const manifestStr = await response.json().MPD
        return {
            manifest: parseDashManifest(manifestStr),
            baseUri: new URL('https://example.com'),
        }
    },
})

Load sets the queue. To begin playback, invoke player.play(). Depending on browser autoplay policies, the first call to play() must be in response to a user interaction such as a click, touch, key, or voice event.

<script>
    function play() {
        // play() rejections should be handled, but are typically recoverable. Reasons for rejection include
        // an AbortError from a track change, or NotAllowed due to not being first called from a user interaction such
        // as a click or keypress.
        // Not catching rejections will result in unhandled promise rejections logging to the console.
        player.play().catch((error) => console.warn(error))
    }
</script>

<body>
    <button onclick="play()">PLAY</button>
</body>

To append tracks to the current queue without stopping the currently playing track, use enqueue.

Track Preload

To preload tracks without adding them to the queue, for example on a mouse hover, use preload. preload creates and caches the track.

Usage example:

<script>
    const track = {
        type: 'src',
        uri: 'https://example.com/myTrack.mp3',
    }

    function preload() {
        player.preload(track)
    }

    function load() {
        player.load(track)
    }
</script>
<body>
    <img
        alt="play"
        src="https://example.com/image.jpg"
        onmouseenter="preload()"
        onclick="load()"
    />
</body>

Preload Cache

If a list of tracks are provided to preload, the cache capacity will automatically grow to accommodate all provided tracks.

Cached preloaded tracks are disposed automatically. When the cache capacity has been reached, the least recently used track will be disposed.

Track Enqueue

To append tracks to the current queue without affecting playback, use enqueue.

Usage example:

player.on('trackActivated', () => {
    if (player.queue.length < 2) {
        // Queue is nearing exhaustion, append more tracks
        player.enqueue(
            {
                type: 'src',
                uri: 'https://example.com/myTrack1.mp3',
            },
            {
                type: 'dash',
                uri: 'https://example.com/myTrack2.mpd',
            },
            {
                type: 'dash',
                uri: 'https://example.com/myTrack3.mpd',
            }
        )
    }
})

Track Events

See TrackControllerEventMap in API Docs for full documentation.

  • trackActivated - Emitted when a track becomes active (carries the track).
  • trackDeactivated - Emitted when the active track is torn down, because it was superseded by another track or unloaded.
  • trackEnded - Emitted once a track has fully finished, after its postroll ad if any. Prefer this over ended to detect true track completion.
  • queueEnded - Emitted when the last track of the playback queue has ended.

When the last track in the queue has ended, it will not automatically be unloaded. To automatically unload when the queue ends, one could write:

player.on('queueEnded', () => player.unload())

When a track ends, a low-level ended event is emitted before the queue moves to the next track; if an ended handler changes the queue, the automatic track transition is canceled. Note that ended also fires for each ad (ads share the media element) and, for a track with a postroll, fires before the postroll plays — so use trackEnded (above) to detect that a track is actually done.

For example, to interrupt an automatic queue transition, one could write:

player.on('ended', () => {
    if (shouldInterrupt) player.clearQueue()
})

Track Preloading/Prefetching Configuration

To change initial cache capacity or number of tracks prefetched, when constructing the player, provide configuration to trackController.

Example:

import { createVinylPlayer } from '@amazon/vinyl'

createVinylPlayer(
    { media: new Audio() },
    {
        trackController: {
            trackPrefetchCount: 3,
            preloadCapacity: 5,
        },
    }
)

Increasing prefetch count can reduce playback delay when rapidly skipping tracks at the cost of increased memory and network usage.

Playback Control

Controlling playback should be done through the Amazon Vinyl player reference, not the media element. This is to ensure that controls are consistent across browsers and devices.

The basic playback operations are play, pause, and seekTo.

play() invokes play() on the media element with additional safety around awaiting track loading. Unlike when using the media element, play and pause may be invoked before the track has finished loading.

play may reject if interrupted from another track load, or if the media element is not 'unlocked' by calling play in response to a user interaction. User interactions include click, touch, tap, key, or voice events. Autoplay policies are browser-dependent, and may not apply to all devices such as televisions.

It is recommended to connect UI elements such as a play button to a synchronous call to play(), and adding a rejection handler which may ignore play rejections.

seekTo seeks the media to the given time. seekTo has additional safety over setting currentTime on the media element directly. seekTo awaits track seekable ranges, ensures seeking is to a seekable time range, and ensures rapid seek operations resolves to the final time.

For full documentation on playback commands, see the API Docs for PlaybackController.

Playback Events

When observing Amazon Vinyl events, use on for event registration.

Example:

const timeUpdateSub = player.on('timeUpdate', (event) => {
    console.log(`currentTime is now ${player.currentTime}.`)
})

Invoke the returned Unsubscribe callback to remove the handler. If the player is disposed, the handlers will be cleared.

Read the API Docs on PlaybackControllerEventMap for the full list of playback events. Most events are directly from the media element, but there are additional second-order events such as 'played', 'waited', or 'mutedChange'.

Stall Detection

Vinyl detects when the play head freezes during playback (no timeUpdate for longer than a threshold) and reports it as a stallEntered/stallEnded pair. Unlike the media's waiting/waited events, a stall is only reported once playback has actually been observed — never during initial loading or while awaiting a seek — and stallEnded carries the full frozen duration.

player.on('stallEntered', () => {
    // The play head has frozen mid-playback; e.g. show a buffering spinner.
})
player.on('stallEnded', (event) => {
    // event.reason is one of 'playing' (resumed), 'pause', 'seeking', 'emptied'.
    // event.duration is the number of seconds the play head was frozen,
    // measured from the last timeUpdate before the freeze.
    console.log(`stalled for ${event.duration}s, ended by ${event.reason}`)
})

The freeze threshold defaults to 1 second and is configurable via the stallThreshold playback option.

Buffer Status

There are two concepts to understand when talking about how much data is buffered: buffered data and fetched data. fetched data refers to data streamed from the network. buffered data refers to data that has finished decoding and decrypting. When indicating to the user how much data is loaded, the fetched time ranges is the more relevant of the two.

To show an indicator for prefetch, use the fetchedRangesChange event and fetchedTimePercent property.

function setPrefetched(percent: number) {
    // Update UI
}

player.on('fetchedRangesChange', () => {
    setPrefetched(player.fetchedTimePercent)
})
setPrefetched(0)

Quality Information

Amazon Vinyl provides access to media quality information for different content types (audio, video, text) through quality accessor methods. Quality metadata progresses through three stages:

  • Streaming Quality: The quality being requested for streaming
  • Buffering Quality: The quality currently being buffered/decoded
  • Playback Quality: The quality currently being played back

Accessing Quality Information

// Get current content types (e.g., Set(['audio', 'video']))
const contentTypes = player.contentTypes

// Get quality for specific content type
const audioStreamingQuality = player.getStreamingQuality('audio')
const videoBufferingQuality = player.getBufferingQuality('video')
const audioPlaybackQuality = player.getPlaybackQuality('audio')

// Returns null if no quality available for the content type
const textQuality = player.getStreamingQuality('text') // null if no text track

Quality Change Events

Listen for quality changes across all content types:

player.on('contentTypesChange', (event) => {
    console.log('Content types changed:', event.previous, '→', event.current)
})

player.on('streamingQualityChange', (event) => {
    console.log(
        'Streaming quality changed:',
        event.previous,
        '→',
        event.current
    )
})

player.on('bufferingQualityChange', (event) => {
    console.log(
        'Buffering quality changed:',
        event.previous,
        '→',
        event.current
    )
})

player.on('playbackQualityChange', (event) => {
    console.log('Playback quality changed:', event.previous, '→', event.current)
})

Adaptive Bitrate Configuration

Adaptive bitrate behavior is configured through player.configure({ abr }) and can be changed at any time; the timeline is re-evaluated on the next quality selection.

Capping Bandwidth

Use abr.maxBandwidth to cap the maximum per-second bandwidth (in bits per second) of any selectable quality. This is a soft cap: if no qualities fit within the limit, the lowest-bandwidth quality is selected so playback remains possible. Setting maxBandwidth to 0 therefore pins playback to the lowest available quality.

maxBandwidth has no effect when strategy is AbrStrategy.LOWEST or AbrStrategy.HIGHEST, since those strategies pin selection regardless of bandwidth.

// Limit selectable qualities to 1.5 Mbps or less.
player.configure({
    abr: {
        maxBandwidth: 1_500_000,
    },
})

// Pin to the lowest available quality.
player.configure({ abr: { maxBandwidth: 0 } })

// Remove the cap.
player.configure({ abr: { maxBandwidth: null } })

Sidecar Text Tracks

Vinyl discovers WebVTT subtitle tracks delivered alongside HLS or DASH content ("sidecar" subtitles) and exposes them through VinylPlayer.

Discovery

For HLS, the player reads EXT-X-MEDIA:TYPE=SUBTITLES renditions from the multivariant playlist. The rendition's URI may point at a .vtt file directly or at a media playlist (.m3u8) whose segments are individual .vtt files; both forms are supported.

For DASH, the player reads <AdaptationSet contentType="text"> (or any <AdaptationSet> with a text/* mimeType). Each <Representation>'s BaseURL chain is resolved against the manifest URL to produce the absolute text track URL. Segmented text codecs (stpp, wvtt) are not surfaced in v1.

Discovery happens automatically when a track loads — applications need only listen for the textTracksChange event or read player.textTracks.

Selecting captions

Caption selection is declarative: it is driven entirely by the text option (VinylOptions.text), so a choice persists across track changes (e.g. across an ad break) and re-resolves against whatever the next source exposes. There is no imperative setActiveTextTrack call.

type CaptionMode = 'on' | 'off' | 'forced'

interface TextTrackSelection {
    id?: string | null // exact track id (wins over the other criteria)
    kind?: TextTrackKind | null // restrict to a kind, e.g. 'captions'
    language?: string | readonly string[] | null // preferred language(s)
    forced?: boolean | null // explicit forced-ness filter (overrides the mode)
}

interface TextTrackControllerOptions {
    enabled?: CaptionMode | null // default 'forced'
    selection?: TextTrackSelection | null
}
  • enabled gates rendering: 'off' shows nothing, 'forced' shows only forced (narrative) tracks, 'on' shows the full subtitle track. The default is 'forced'.
  • selection.language unset (or empty) falls back to the platform's navigator.languages.
  • selection.forced, when set, is an explicit filter that overrides the forced/full split implied by enabled.
// Default (no config): forced captions in the platform's preferred languages.

// Full subtitles in a specific language:
player.configure({ text: { enabled: 'on', selection: { language: 'en' } } })

// Forced narrative captions only, for a language:
player.configure({ text: { enabled: 'forced', selection: { language: 'es' } } })

// A specific discovered track by id:
player.configure({ text: { enabled: 'on', selection: { id: someTrack.id } } })

// Turn captions off:
player.configure({ text: { enabled: 'off' } })

The player still exposes the discovered list, the active selection, and change events (read-only):

console.log(player.textTracks)
// → [{ id, kind: 'subtitles', language: 'en', label: 'English', default: true,
//      forced: false, characteristics: [], uri: 'https://.../subs/en.vtt',
//      mimeType: 'text/vtt' }, ...]
console.log(player.activeTextTrack)

player.on('textTracksChange', (event) => {
    console.log('Text tracks updated:', event.current)
})
player.on('activeTextTrackChange', (event) => {
    console.log('Active text track:', event.current)
})
player.on('textTrackError', (event) => {
    console.warn('Failed to load track', event.track.label, event.error)
})

Forced subtitles

A track flagged forced carries essential text (e.g. translations of foreign-language dialogue) that is meant to display even when the user has not enabled full subtitles. A forced track shares its language with the full track, so selection distinguishes them by the forced flag rather than language alone. Media characteristics / accessibility roles are surfaced in characteristics (HLS CHARACTERISTICS, DASH Role/Accessibility).

Because enabled defaults to 'forced', out of the box the player shows a forced track for the platform's preferred languages when one is discovered, and nothing otherwise.

Rendering

When a track is selected, Vinyl fetches the WebVTT, parses it, and adds the cues to a TextTrack on the underlying media element via HTMLMediaElement.addTextTrack. By default that track is 'showing' and the browser renders the cues (styleable only through the limited ::cue pseudo-element).

For full control over caption styling and placement, inject a TextTrackRenderer — Vinyl ships HtmlTextTrackRenderer, which paints cues as an HTML overlay you position yourself:

import { createVinylPlayer, HtmlTextTrackRenderer } from '@amazon/vinyl'

const captions = new HtmlTextTrackRenderer()
const player = createVinylPlayer({ media, textTrackRenderer: captions })
videoContainer.appendChild(captions.element) // position it over the video

With a renderer injected, the DOM TextTrack is kept 'hidden' (used only for cue timing) and the renderer paints the active cues, honoring each cue's WebVTT settings (position / line / size / align / vertical), cue payload tags (<c.class>, <i>, <b>, <v>, <lang>, …), and STYLE-block ::cue rules. Implement the TextTrackRenderer interface for a fully custom renderer.

Ad Breaks (Server-Guided Ad Insertion)

Vinyl surfaces server-guided ad breaks through a single, provider-agnostic API on VinylPlayer. Today this is driven by HLS Interstitials (SGAI); the same API is intended to also carry DASH SCTE-35 splices, so applications observe one interface regardless of the streaming protocol.

Discovery

For HLS, the player reads EXT-X-DATERANGE tags with CLASS="com.apple.hls.interstitial" from the media playlist. Each interstitial is mapped to an abstract AdBreakInfo:

  • Its wall-clock START-DATE is converted to a media-timeline start time by correlating it with the playlist's EXT-X-PROGRAM-DATE-TIME anchor.
  • Its duration is resolved from DURATION, else the START-DATE/END-DATE span, else PLANNED-DURATION.
  • An X-ASSET-URI yields a single resolved ad; an X-ASSET-LIST yields a break whose assets are resolved asynchronously (empty ads up front).
  • The break is classified as preroll, midroll, or postroll.

Discovery happens automatically when a track loads — applications need only listen for the currentTrackAdsChange event or read player.currentTrackAds.

API

// Inspect the ad breaks known for the current media (ordered by start time).
console.log(player.currentTrackAds)
// → { trackUri, adBreaks: [{ id, startTime: 12, duration: 15,
//      placement: 'midroll', restrict, once, resumeOffset,
//      ads /* resolver → AdInfo[] */ }, ...] }

// Inspect the break currently containing the playhead, if any.
console.log(player.currentAdBreak)

// React to changes and boundary crossings.
player.on('currentTrackAdsChange', (event) => {
    console.log('Ad breaks updated:', event.current)
})
player.on('adBreakEntered', ({ adBreak }) => {
    console.log('Entered ad break', adBreak.id)
})
player.on('adBreakCompleted', ({ adBreak, resumePosition }) => {
    console.log('Completed ad break', adBreak.id, '→ resume at', resumePosition)
})

adBreakEntered fires when the playhead enters a break; adBreakCompleted fires when it finishes (all ads played, were skipped, or it had none), carrying the resumePosition at which content resumes. Breaks with an unknown duration surface in currentTrackAds but never mark the playhead as inside a break until their span resolves. See ADS.md for the full ad event set.