npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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:

  1. External (sideloaded) subtitles — attach subtitle files to any source, including progressive MP4 (which, unlike HLS/DASH, cannot advertise its own subtitle renditions).
  2. 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-subtitle

The 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 VideoPlayer constructor and replaceAsync, not the synchronous replace), 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 earlier

This 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 (01) 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, backgroundRadius and applyEmbeddedStyles are 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. Leaving backgroundRadius unset 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.