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

# Sideload external subtitles

> Learn how to register, select, and render external subtitle files that are not in the HLS manifest in the FastPix iOS Player.

Learn how to register, select, and render external subtitle files that are not in the HLS manifest in the FastPix iOS Player.

The FastPix iOS Player SDK can play a subtitle file that is not in the manifest: a `.vtt` or `.srt` file side-loaded from your own storage, alongside an asset you cannot re-package. The SDK supports two formats, WebVTT and SubRip. SubRip files are converted to WebVTT on the device and reported as `srt_converted`, so a timing problem can be traced back to the conversion step.

External tracks appear in `getSubtitleTracks()` alongside in-manifest tracks and are selected with the same `setSubtitleTrack(trackId:)`. External cues always arrive through the `onSubtitleCueChange` callback, so your app renders them itself. For the shared subtitle workflow, see [Switch subtitle tracks](/docs/ios-player/switch-subtitle-tracks).

## Register an external subtitle

Register the track **after** `prepare(playbackID:playbackOptions:)`. The subtitle manager is created during player setup, so registering before it exists fails with `.playerNotPrepared`.

Registration is synchronous and performs no I/O. It answers only what can be decided from the descriptor you pass. Questions that need the file bytes, such as whether the file exists or contains readable cues, are answered later through the delegate when the track is first selected.

```swift
// External cues always arrive through onSubtitleCueChange, so render them yourself.
playerViewController.subtitleTrackDelegate = self

let descriptor = FastPixExternalSubtitleTrack(
    sourceID: "en-forced-v1",
    url: "https://cdn.example.com/subs/en.srt",
    language: .declared("en"),
    label: "English (Forced)",
    format: nil,            // inferred from the path extension
    isDefault: false,
    isForced: true
)

switch playerViewController.registerExternalSubtitle(descriptor) {
case .success(let track):
    // `track.id` is what you pass to setSubtitleTrack(trackId:)
    playerViewController.setSubtitleTrack(trackId: track.id)
case .failure(let error):
    print("[Subtitles] registration refused:", error.explanation)
}
```

## Unregister and inspect tracks

Registering re-publishes `onSubtitlesLoaded(tracks:)`, so a track registered after the manifest has already loaded still appears in your menu. Unregistering the track that is currently rendering stops its cue stream and fires `onSubtitleChange(track: nil)`.

```swift
let removed = playerViewController.unregisterExternalSubtitle(sourceID: "en-forced-v1")

for track in playerViewController.registeredExternalSubtitles {
    print(track.sourceID, track.label, track.format as Any)
}
```

## Declare the format explicitly

The SDK infers the format from the URL's path extension only. The query string is not read as a statement about the file's format. A signed URL such as `…/en.vtt?token=ey…` is inferred correctly, but `…/subtitles?file=en.srt` is not, so you must declare the format.

```swift
let descriptor = FastPixExternalSubtitleTrack(
    sourceID: "es-1",
    url: "https://cdn.example.com/subtitles?file=es",
    language: .declared("es"),
    label: "Spanish",
    format: .subRip          // required: no usable extension
)
```

## Handle external subtitle events

Conform to `FastPixSubtitleTrackDelegate` to receive load reports, load failures, and rendering limitations:

```swift
extension VideoPlayerViewController: FastPixSubtitleTrackDelegate {

    // The file was fetched and parsed.
    func onExternalSubtitleLoaded(report: FastPixExternalSubtitleLoadReport) {
        print("[Subtitles]", report.sourceID,
              "ingest:", report.ingestFormat.rawValue,     // "vtt" or "srt_converted"
              "cues:", report.cueCount,
              "firstCue:", report.firstCueStart as Any)
    }

    // The file could not be turned into cues.
    func onExternalSubtitleLoadFailed(sourceID: String,
                                      failure: FastPixExternalSubtitleLoadFailure) {
        print("[Subtitles]", sourceID, "failed:", failure.explanation)
    }

    // Fired when host rendering becomes active, with what is now true that was not before.
    func onSubtitleRenderingLimitations(_ limitations: [FastPixExternalSubtitleLimitation]) {
        limitations.forEach { print("[Subtitles] limitation:", $0.explanation) }
    }
}
```

The same list is queryable at any time, which makes a "subtitles vanished on AirPlay" report answerable in one line:

```swift
print(playerViewController.subtitleRenderingLimitations)
```

## Bound the fetching window

Control how far ahead and behind the playhead the SDK fetches and retains cues:

```swift
playerViewController.subtitleWindowPolicy = FastPixSubtitleWindowPolicy(
    lookAhead: 60,
    lookBehind: 30,
    maxConcurrentFetches: 3,
    maxRetainedCues: 5_000
)
```

| Option                 | Type           | Description                                                                   | Default |
| ---------------------- | -------------- | ----------------------------------------------------------------------------- | ------- |
| `lookAhead`            | `TimeInterval` | How far ahead of the playhead to fetch                                        | `60`    |
| `lookBehind`           | `TimeInterval` | How far behind to keep fetching, so a short seek back does not blank the cues | `30`    |
| `maxConcurrentFetches` | `Int`          | Simultaneous segment fetches                                                  | `3`     |
| `maxRetainedCues`      | `Int`          | Cues held in memory; those furthest from the playhead are evicted first       | `5000`  |

## FastPixExternalSubtitleTrack model

| Property    | Type                           | Description                                                                                 |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------- |
| `sourceID`  | `String`                       | Your identifier. Becomes the identity discriminator and must be stable across registrations |
| `url`       | `String`                       | `http`, `https`, or `file`                                                                  |
| `language`  | `FastPixSubtitleLanguage`      | `.declared("en")`, or `.undetermined` when you do not know. Never guessed from `label`      |
| `label`     | `String`                       | The only text you can put in a subtitle menu; must not be empty                             |
| `format`    | `FastPixSubtitleFileFormat?`   | `nil` to infer from the path extension                                                      |
| `isDefault` | `Bool`                         | Advisory only. The SDK never auto-selects an external track on the strength of this         |
| `isForced`  | `Bool`                         | Forced-narrative flag, for your own menu presentation                                       |
| `identity`  | `FastPixSubtitleTrackIdentity` | Derived. `stringValue` is `external:<sourceID>:<languageTag>` and is the `SubtitleTrack.id` |

## FastPixSubtitleFileFormat and FastPixSubtitleIngestFormat

| Case                                           | Meaning                                                                |
| ---------------------------------------------- | ---------------------------------------------------------------------- |
| `.webVTT`                                      | Recognized from `.vtt` or `.webvtt`. Parsed as delivered               |
| `.subRip`                                      | Recognized from `.srt`. Converted to WebVTT on the device, then parsed |
| `.webVTT` (ingest, `"vtt"`)                    | Cues came straight from a WebVTT file                                  |
| `.subRipConverted` (ingest, `"srt_converted"`) | The SDK rewrote the file to produce these cues                         |

## FastPixExternalSubtitleRegistrationError

Decidable from the descriptor alone, reported synchronously at the call site. Every case has an `explanation`.

| Case                           | Meaning                                                                                     |
| ------------------------------ | ------------------------------------------------------------------------------------------- |
| `.emptySourceID`               | An empty ID makes every external track collide with every other one                         |
| `.duplicateSourceID(String)`   | Already registered; unregister the first one to replace it                                  |
| `.emptyLabel`                  | An unnamed row is not something a viewer can choose between                                 |
| `.malformedURL(String)`        | Not a parseable URL, or a relative reference with no scheme                                 |
| `.unsupportedScheme(String)`   | Only `http`, `https`, and `file` are accepted                                               |
| `.indeterminateFormat(String)` | The path extension says nothing. Pass `format:`; the SDK will not read the file to find out |
| `.playerNotPrepared`           | Called before `prepare(playbackID:playbackOptions:)`                                        |

## FastPixExternalSubtitleLoadFailure

Needs the file bytes, so it is reported asynchronously when the track is first selected.

| Case                                      | Meaning                                                                                 |
| ----------------------------------------- | --------------------------------------------------------------------------------------- |
| `.unreachable(FastPixSubtitleFetchError)` | The file could not be retrieved; carries the transport's own account                    |
| `.noCues`                                 | The file arrived and parsed to nothing. Check that the declared format matches the file |

## FastPixExternalSubtitleLoadReport

| Property        | Type                          | Description                                                                                                                     |
| --------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `sourceID`      | `String`                      | The track that loaded                                                                                                           |
| `ingestFormat`  | `FastPixSubtitleIngestFormat` | `vtt`, or `srt_converted` when the SDK rewrote the file                                                                         |
| `cueCount`      | `Int`                         | Cues parsed                                                                                                                     |
| `firstCueStart` | `TimeInterval?`               | The cheapest sanity check on a timing complaint. A first cue at `0` for dialogue starting at 12 s is a file for the wrong asset |

## FastPixExternalSubtitleLimitation

| Case                                 | Meaning                                                                                                                                                     |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.notRenderedInPictureInPicture`     | Picture in Picture presents the player's video surface; a cue drawn into a sibling view is not part of it                                                   |
| `.notRenderedOnExternalDisplay`      | The same for AirPlay and a wired display: the surface travels, your view hierarchy does not                                                                 |
| `.systemCaptionAppearanceNotApplied` | Settings > Accessibility > Subtitles & Captioning styles AVFoundation's rendering, not your label                                                           |
| `.notRenderedByAVFoundation`         | AVFoundation renders legible tracks that exist in the asset. A side-loaded file is not in the asset, so `.avFoundation` is not an available renderer for it |

Two convenience lists are published: `FastPixExternalSubtitleLimitation.hostRendering` (the first three) and `.externalTrack` (all four).

> **Note:**
>
> * `registerExternalSubtitle(_:)` performs no I/O and returns synchronously. It does not stat the file, including for a `file://` URL, which can point at an iCloud Drive path where `fileExists` blocks on a download. Existence at registration would also say nothing about existence at selection.
> * An external track has exactly one possible renderer. The SDK selects `.host` for it regardless of `subtitleRenderer`, because AVFoundation renders legible tracks that exist in the asset and a side-loaded file does not. Implement `onSubtitleCueChange(information:)` to see anything.
> * For in-manifest tracks the renderer is yours to choose. `subtitleRenderer` defaults to `.avFoundation`, which draws inside the player's own surface and does not emit the cue stream; set `.host` to receive cues instead. Do not do both, or a host ends up rendering every subtitle twice.
> * `sourceID` is yours to choose precisely so it can be stable across app launches. An SDK-generated ID would change on every relaunch, and a host persisting the viewer's choice would never find the track again.
> * `isDefault` does not auto-select. A side-loaded default would override the stream's own `DEFAULT=YES` rendition without you having asked for that.
> * The composite ID (`external:<sourceID>:<language>`) exists so that two tracks in the same language are addressable separately. Legacy language-only IDs passed to `setSubtitleTrack(trackId:)` still resolve.

#### Frequently asked questions

You can sideload WebVTT (`.vtt`) and SubRip (`.srt`) files. SubRip files are converted to WebVTT on the device and reported with the ingest format `srt_converted`, so a timing problem can be traced back to the conversion step. The SDK infers the format from the URL's path extension, or you can set `format:` explicitly when the URL has no usable extension.

An external track has exactly one possible renderer, so the SDK renders it through the host, not AVFoundation. You must set the `subtitleTrackDelegate` and implement `onSubtitleCueChange(information:)` to draw the cues yourself. Also register the track after `prepare(playbackID:playbackOptions:)`; registering earlier fails with `.playerNotPrepared`.

Yes. The `url` on `FastPixExternalSubtitleTrack` accepts `http`, `https`, and `file` schemes. Registration performs no I/O and does not check that the file exists, including for a `file://` URL, so a missing or unreadable file is reported asynchronously through `onExternalSubtitleLoadFailed(sourceID:failure:)` when the track is first selected.

Host-rendered cues are drawn into a sibling view, and AirPlay, external displays, and Picture in Picture move the player's video surface rather than your view hierarchy. The SDK reports this through `onSubtitleRenderingLimitations(_:)`, and the current limitations are queryable at any time with `subtitleRenderingLimitations`.