expo-video-subtitle
v0.8.0
Published
A cross-platform, performant video component for React Native and Expo with external subtitle sideloading and custom subtitle styling.
Maintainers
Readme
expo-video-subtitle
A cross-platform, performant video component for React Native and Expo with Web support.
This package is a fork of expo-video (SDK 56 / 56.1.4) that adds two subtitle capabilities on top of the original module:
- External (sideloaded) subtitles — attach subtitle files to any source, including progressive MP4 (which, unlike HLS/DASH, cannot advertise its own subtitle renditions).
- Custom subtitle styling — control the text color, background, font, edge, and position of the rendered captions.
Everything else in the API is identical to expo-video, so the upstream documentation applies to all other props and methods.
What's different from expo-video
Nothing in the existing API was renamed, removed, or given new behaviour — the changes are purely additive, plus one packaging change.
Added
| API | Where | Description |
| --- | --- | --- |
| subtitleTracks?: SubtitleSource[] | VideoSource | Attach external subtitle files to a source. See Sideloading subtitles onto MP4. |
| subtitleStyle?: SubtitleStyle | VideoView prop | Customise caption appearance. See Styling subtitles. |
| SubtitleSource, SubtitleStyle, SubtitleEdgeType | exported types | Supporting TypeScript types. |
Changed
- Native code is compiled from source. Upstream ships a precompiled Android AAR and iOS
XCFramework; those are removed here, because a prebuilt binary would ignore the modified native
code. Expect your first native build after installing to take longer than with
expo-video.
Removed
- Nothing from the public API. Only the upstream prebuild artifacts (
local-maven-repo/,prebuilds/,spm.config.json) — see above.
Maintaining this fork or pulling in a newer expo-video? See FORK.md for the full change
manifest and the upstream sync procedure.
Installation
npm install expo-video-subtitleThe native module registers under the same name as expo-video (ExpoVideo). It is a drop-in replacement: a project must use either expo-video or expo-video-subtitle, not both. Remove expo-video from your dependencies and import from expo-video-subtitle instead:
import { useVideoPlayer, VideoView } from 'expo-video-subtitle';Sideloading subtitles onto MP4
Pass a subtitleTracks array on the VideoSource. The tracks are merged into the media and surface through player.availableSubtitleTracks, so they are selected the same way as embedded tracks.
import { useVideoPlayer, VideoView } from 'expo-video-subtitle';
const player = useVideoPlayer({
uri: 'https://cdn.example.com/movie.mp4',
subtitleTracks: [
{ uri: 'https://cdn.example.com/en.vtt', language: 'en', label: 'English', default: true },
{ uri: 'https://cdn.example.com/id.srt', language: 'id', label: 'Indonesia', format: 'srt' },
{ uri: 'https://cdn.example.com/signs.ass', language: 'ja', label: 'Signs', format: 'ass' },
],
});
// After the source loads, pick a track:
player.subtitleTrack = player.availableSubtitleTracks[0];SubtitleSource fields: uri (local file:// or remote http(s)://), language (BCP-47 / ISO 639), optional label, optional format ('vtt' | 'srt' | 'ass' | 'ssa', inferred from the extension when omitted), optional default, and optional bottomOffset (iOS — see Moving subtitles off burned-in captions).
Pass format whenever you know it. A URL that serves the file without an extension — a Directus asset at /assets/<uuid>, say — leaves nothing to infer from, and the WebVTT default would decode a SubRip or ASS file to zero cues in silence.
| | Android | iOS |
| --- | --- | --- |
| Mechanism | MediaItem.SubtitleConfiguration (Media3 merges into a MergingMediaSource) | AVMutableComposition (copies the video/audio tracks and inserts a .text track) |
| Formats | VTT, SRT, ASS/SSA, TTML natively | VTT natively; SRT and ASS/SSA are auto-converted to VTT; remote files are downloaded to a local cache first |
iOS limitations: sideloading only runs on the asynchronous loading path (the default
VideoPlayerconstructor andreplaceAsync, not the synchronousreplace), and only for progressive (non-HLS, non-DRM) sources. HLS/DASH already carry their own subtitle renditions.
Overlapping subtitles
Subtitle files often contain cues whose timestamps overlap — two speakers talking over each other, or dialogue shown alongside on-screen text. Cues that are on screen at the same time are rendered as a single stacked block, with the cue that started earliest at the bottom:
Apa mereka berdua berteman, ya? <- started later
Nah, ayo pergi. <- started earlierThis matches how WebVTT stacks cues, and behaves the same on Android and iOS. Cues that carry
explicit positioning (SubRip's {\anN} tags) are left where they were placed and are not merged.
Any inline formatting the merged cues carry is preserved, so it survives an overlap unchanged — see
Inline formatting from the track for when that formatting renders.
A cue never changes line while it is on screen (Android). Because the block is anchored at its bottom, a cue would otherwise drop down the moment the cue below it ended — moving the text you are reading at that moment. The line a finished cue leaves behind is held empty instead, and the next cue to start reclaims the lowest empty line rather than stacking on top:
Apa mereka berdua berteman, ya? both on screen
Nah, ayo pergi.
Apa mereka berdua berteman, ya? the lower cue ended — the upper one stays put
(held empty, reclaimed by whichever cue starts next)The empty line can also sit between two cues when the middle one finishes first. Once nothing is left on screen, the stack resets and the next cue starts from the bottom again. This is Android-only; on iOS the system renderer decides the layout.
Moving subtitles off burned-in captions
Some releases have subtitles burned into the picture. A sideloaded track then lands in the same place and the two overlap. Both platforms can lift the subtitles clear of them, but the setting lives in a different place on each:
// Android — a VideoView prop, applies to every track, updates live
<VideoView player={player} subtitleStyle={{ bottomOffset: 0.22 }} />
// iOS — a field on the subtitle track itself
subtitleTracks: [{ uri: 'https://cdn.example.com/id.srt', language: 'id', bottomOffset: 0.22 }]Both are a fraction of the video height (0–1) measured up from the bottom; larger values sit
higher. The Android default is 0.08.
The split is not an oversight. On iOS the position has to travel inside the WebVTT itself — the system
renders legible tracks and AVPlayerItem.textStyleRules carries no positioning — so it is written in
while the subtitle is merged into the video. That happens from the VideoSource alone, before any
VideoView has supplied a style, so a style prop could not reach it without rebuilding the player item
on every load. Two consequences on iOS: it applies to sideloaded tracks only, and changing it
takes effect the next time the source loads.
| | Android | iOS |
| --- | --- | --- |
| Where | subtitleStyle.bottomOffset | subtitleTracks[].bottomOffset |
| Applies to | every track, embedded included | sideloaded tracks only |
| Updates | live | on next source load |
| Anchors | the bottom of the subtitle block | the bottom of the subtitle block |
| Cues that position themselves | left alone | left alone |
That last row is worth knowing: subtitles carrying their own placement — SubRip {\anN} tags, WebVTT
cues with a line: setting — were positioned deliberately by whoever wrote them, so neither platform
moves them.
ASS/SSA is the case where the two platforms diverge. Media3's SsaParser gives every cue an
explicit line, so subtitleStyle.bottomOffset moves nothing on an ASS track on Android. On iOS the
converter deliberately emits no line: for bottom-centre cues (\an2, the default for dialogue),
so ordinary dialogue still shifts while genuine \an8 signs stay put. Fixing the Android side means
a custom SSA parser that can tell a style default from a real \pos; it is not in this release.
The iOS cue setting is written as line:N%,end. The trailing ,end is WebVTT's line alignment
component, and it is what makes the percentage refer to the bottom edge of the cue box — so the two
platforms anchor the same way and one value is correct on both. AVFoundation honours both parts,
confirmed on iOS 26.5.
For older targets, note that a WebVTT parser predating that component discards the whole line setting
rather than ignoring the component, which would leave captions unpositioned; there is no form that
degrades more gently, since both behaviours come from the same setting.
Styling subtitles
Pass a subtitleStyle prop to VideoView. It applies to whichever subtitle track is currently displayed, whether embedded or sideloaded. Colors accept a hex string (#RGB, #RRGGBB, #RRGGBBAA) or transparent.
<VideoView
player={player}
subtitleStyle={{
textColor: '#FFFFFF',
backgroundColor: '#000000A0', // box drawn behind the text
backgroundRadius: 8, // Android only — corner radius of that box, in dp
windowColor: 'transparent', // background of the whole cue region
fontSize: 20, // Android: absolute sp · iOS: percent relative to default (100 = default)
fontFamily: 'Helvetica',
bold: true,
edgeType: 'outline', // 'none' | 'outline' | 'dropShadow' | 'raised' | 'depressed'
edgeColor: '#000000',
bottomOffset: 0.1, // Android only — fraction of the view height (0–1)
applyEmbeddedStyles: true, // Android only — keeps the track's italic/underline; default true
}}
/>| Mechanism | Android | iOS |
| --- | --- | --- |
| Implementation | CaptionStyleCompat on the PlayerView subtitle view | AVPlayerItem.textStyleRules |
iOS note: styling relies on
textStyleRules, which only affects WebVTT captions the system renders. The device's Settings → Accessibility → Subtitles & Captioning preferences take precedence when enabled.edgeType/edgeColor,bottomOffset,backgroundRadiusandapplyEmbeddedStylesare Android-only.
Rounded background
backgroundRadius rounds the corners of the backgroundColor box. It is a dp value, and it does
nothing unless backgroundColor is set to something that isn't fully transparent:
<VideoView player={player} subtitleStyle={{ backgroundColor: '#000000A0', backgroundRadius: 8 }} />One box is drawn per rendered line, so a caption that wraps onto two lines gets two rounded boxes stacked directly on top of each other — the way VLC and most desktop players draw them. Boxes on adjacent lines share an edge; the rounding is on the outside of the block.
windowColor is not affected and stays rectangular. The two are different things: the window is
the region the caption occupies, the background is the box that hugs the text.
Media3 has no rounded-caption support of its own — it paints both the background and the window with
canvas.drawRect. This option is implemented by rendering the background in a second subtitle view placed behind the text, so it costs one extra view and one cue listener per player, and only while it is switched on. LeavingbackgroundRadiusunset takes the original code path exactly as before.
Inline formatting from the track
Subtitle files carry formatting of their own: <b>, <i>, <u> and <font color> in SRT, <c.yellow>
and <b> in WebVTT, and a whole styling language in ASS. The module splits it in two.
Colour, font size, font family and weight are always discarded, on both platforms. They are removed
while the file is being parsed, before any view exists, so subtitleStyle is the single thing deciding
how captions look. There is no way to opt back into a track's own colours — a subtitle shipping yellow
72pt bold text cannot override the size your user picked.
Italic, underline and strikethrough are kept, and applyEmbeddedStyles: false is the blunt switch
that drops them too:
<VideoView player={player} subtitleStyle={{ textColor: '#FFFFFF', applyEmbeddedStyles: false }} />A track's own placement is unaffected either way, so an \an8 sign still sits at the top.
Two asymmetries worth knowing:
| | Android | iOS |
| --- | --- | --- |
| Strikethrough | renders | cannot render — WebVTT has no strikethrough tag and CoreMedia has no attribute for one |
| Italic/underline in ASS | from the [V4+ Styles] Style: line only | from the style line and inline {\i1} |
| applyEmbeddedStyles | honoured | ignored; italic and underline always render |
The Android ASS limitation is Media3's: SsaStyle.Overrides parses only \an, \pos and \move,
then stripStyleOverrides deletes every remaining {...} block, so an inline {\i1} never reaches
the renderer.
Fonts
fontFamily accepts the same name you would give a <Text> component, so a font bundled with your
app can be used for subtitles:
<VideoView player={player} subtitleStyle={{ fontFamily: 'Inter-Regular', bold: true }} />The simplest way to make a custom font available on both platforms is expo-font's config plugin,
which bundles the file into res/font on Android and registers it via UIAppFonts on iOS:
["expo-font", { "fonts": ["./assets/fonts/Inter-Regular.ttf"] }]| Font source | Android | iOS |
| --- | --- | --- |
| Built-in system families (sans-serif, serif, monospace, cursive, …) | ✅ | ✅ (use iOS names, e.g. Helvetica Neue) |
| Bundled via expo-font config plugin | ✅ resolved from res/font | ✅ resolved from UIAppFonts |
| React Native convention (assets/fonts/<name>.ttf) | ✅ via RN's font manager | — |
A font name that matches nothing falls back to the default typeface silently — neither platform reports an unresolved family. If the font doesn't change, check the spelling first: the name is the font's family name, not the filename.
License
MIT. Based on expo-video by 650 Industries, Inc.
