expo-video-subtitle 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,133 @@ Changes inherited from the upstream base version are not repeated here — see t
7
7
  [upstream changelog](https://github.com/expo/expo/blob/main/packages/expo-video/CHANGELOG.md)
8
8
  for the history of everything that isn't subtitle related.
9
9
 
10
+ ## 0.5.0
11
+
12
+ ### Added
13
+
14
+ - **`SubtitleSource.format` accepts `'ass'` and `'ssa'`.** Advanced SubStation Alpha is what anime
15
+ releases actually ship, and it is the only common format that can place a caption anywhere on
16
+ screen rather than at the bottom.
17
+
18
+ The two platforms get there differently. **Android** renders the original file: Media3's
19
+ `DefaultSubtitleParserFactory` already accepts `MimeTypes.TEXT_SSA`, so this is a MIME mapping and
20
+ nothing more. **iOS** converts to WebVTT inside the module at load time, because `AVFoundation`
21
+ reads only WebVTT as a `.text` track.
22
+
23
+ What survives: `\an1`-`\an9` and the legacy `\a`, `\pos`, `\N` / `\n` / `\h`, italic and
24
+ underline. What is dropped: colour, font size, font weight, karaoke timings, transforms, rotations,
25
+ borders and shadows, `\move`, `\fad`, `\clip`, vector drawings (`\p1`), and `Comment:` events.
26
+
27
+ On iOS, a `\an8` sign lands at `line:5%`, matching Media3's `SsaParser.DEFAULT_MARGIN` so the same
28
+ sign sits in the same place on both platforms.
29
+
30
+ - **iOS:** `.srt` files carrying `{\anN}` leaked from an ASS converter now position correctly instead
31
+ of printing the tag on screen. The SubRip path was a two-line regex over timestamps; it is now a cue
32
+ block parser, because a tag found in a cue's *text* has to become a setting on the *timing line*
33
+ above it.
34
+
35
+ - **iOS:** subtitle files that are not UTF-8 are decoded instead of being dropped without a message.
36
+ The ladder is BOM sniff, UTF-8, then Windows-1252/1251, Shift-JIS, Big5, GB 18030, and finally
37
+ Latin-1, which never fails. Previously `String(data:encoding:.utf8)` returned nil for a Windows-1251
38
+ file and the whole track vanished silently.
39
+
40
+ - **`scripts/verify-subtitle-converter.sh`** compiles the shipped converter against 87 assertions and
41
+ runs them. The tag table, the `\an` to cue-settings mapping and the escaping order are all pure
42
+ string transforms, and all three fail silently on a device.
43
+
44
+ ### Changed
45
+
46
+ - **Colour, font size and font weight carried by a subtitle file are now always ignored, on both
47
+ platforms.** `subtitleStyle` is the only thing that decides them. This is deliberate and there is no
48
+ way to opt out: a track shipping yellow 72pt bold text should not override the size a user chose in
49
+ your app's settings.
50
+
51
+ On Android this moved into the parser (`SubtitleSpanFilter`), because `SubtitleView`'s own
52
+ `applyEmbeddedStyles` flag is all-or-nothing and, when off, strips italic along with everything else.
53
+ Note it never touches `line`, `position` or `textAlignment`, so a track's placement survives either
54
+ setting.
55
+
56
+ - **`subtitleStyle.applyEmbeddedStyles` now defaults to `true`** and means "keep the track's italic,
57
+ underline and strikethrough". If you want the old flattened look, set it to `false` explicitly.
58
+
59
+ - **iOS:** subtitle text is now HTML-escaped, so a cue containing `<`, `&` or `-->` renders literally
60
+ instead of being read as markup or as a cue boundary.
61
+
62
+ - **iOS:** a sideloaded `.vtt` is no longer passed through untouched. It goes through the same
63
+ sanitising pass, because `<c.yellow>` and `<b>` are a WebVTT file's colour and weight and belong to
64
+ `subtitleStyle` like every other track's do.
65
+
66
+ - **iOS:** the cached rendition's `formatVersion` moved to `v3` and the cache key now includes the
67
+ format, so an install that has already played a track picks up the newly converted file. Without the
68
+ bump the change appears not to work on exactly the devices used to test it.
69
+
70
+ ### Known limitations
71
+
72
+ - **Strikethrough renders on Android only.** WebVTT has no strikethrough tag and CoreMedia has no text
73
+ markup attribute for one, so there is no iOS workaround to reach for.
74
+
75
+ - **`bottomOffset` does not move ASS cues on Android.** `SsaParser` gives every cue an explicit line
76
+ and `SubtitlePainter` only applies `bottomPaddingFraction` to cues without one. On iOS the converter
77
+ emits no `line:` for bottom-centre cues, so ordinary dialogue still shifts there. Fixing Android
78
+ means a custom SSA parser that can tell a style default from a real `\pos`.
79
+
80
+ - **Simultaneous ASS cues at the same alignment can overlap on Android.** Media3 does no collision
81
+ avoidance between positioned cues; libass does. Two speakers talking over each other in an `.ass`
82
+ track will overprint.
83
+
84
+ - **Android renders ASS italic and underline from the `[V4+ Styles]` `Style:` line, not from inline
85
+ `{\i1}`.** Media3's `SsaStyle.Overrides` parses only `\an`, `\pos` and `\move` and then deletes
86
+ every remaining `{...}` block. iOS handles both.
87
+
88
+ - **`position:`, `align:` and the `,center` line alignment are newly relied on and unverified on
89
+ device.** Only `line:N%,end` has been confirmed (iOS 26.5). CoreMedia exposes a matching attribute
90
+ for each WebVTT cue setting, which is the reason to expect them to work, but there is no attribute
91
+ for line-align or position-align, so AVFoundation resolves those internally and internal resolution
92
+ is exactly the kind of thing that can be partial. The mapping emits nothing at all for `\an2`, omits
93
+ the default `,start`, and avoids `position-align` and `size:` entirely, so the common case is
94
+ byte-shaped like the output already known to work.
95
+
96
+ ## 0.4.1
97
+
98
+ ### Fixed
99
+
100
+ - **iOS:** `SubtitleSource.bottomOffset` now anchors the **bottom** of the subtitle block, the same way
101
+ Android does, so one value places captions identically on both platforms.
102
+
103
+ 0.4.0 emitted a bare WebVTT `line:` percentage, which anchors the *top* of the cue box. A cue that
104
+ wrapped onto two lines therefore sat a full line lower on iOS than on Android — 5–13% of the video
105
+ height at typical caption sizes, comparable to the offset itself, and enough to drop the second line
106
+ back over the burned-in captions the offset exists to escape. The cue setting is now written as
107
+ `line:N%,end`, where the trailing `,end` is WebVTT's line alignment component and is what makes the
108
+ percentage refer to the bottom edge.
109
+
110
+ This also fixes `bottomOffset: 0`, which as a bare `line:100%` put the top of the cue box at the bottom
111
+ edge of the video and hid the subtitle completely. It now means "sit at the bottom", matching Android.
112
+
113
+ AVFoundation honours both parts — confirmed on iOS 26.5, where the caption's bottom edge lands at the
114
+ requested percentage and clears a burned-in subtitle exactly as the Android side does at the same
115
+ value. Worth knowing for older targets, though: a WebVTT parser that predates the alignment component
116
+ discards the entire `line` setting rather than ignoring the component, so on such a parser captions
117
+ would be left unpositioned instead of top-anchored. Bottom anchoring cannot be expressed any other
118
+ way — it is the same setting — so confirm the offset still lands on the oldest OS you support.
119
+
120
+ - **iOS:** the cached rendition of a rewritten subtitle is keyed on the format version as well as on the
121
+ URL, language and offset. Without it, an install that had already rendered a given offset would be
122
+ handed the file written by the previous version and the fix above would appear not to work — on
123
+ exactly the devices being used to test it.
124
+
125
+ - **iOS:** the same cache key joins its parts on NUL instead of a hyphen. A hyphen occurs in both
126
+ variable parts — BCP-47 tags carry one (`pt-BR`) and so does almost every URL — so two different
127
+ subtitles could flatten to the same string and be served each other's rewritten file.
128
+
129
+ ### Documentation
130
+
131
+ - **Android:** `subtitleStyle.bottomOffset` was documented as a fraction of the *view* height. It is a
132
+ fraction of the content frame, which under the default `contentFit="contain"` is the displayed picture
133
+ — `SubtitleView` sits inside `AspectRatioFrameLayout`, which `RESIZE_MODE_FIT` shrinks to the video's
134
+ aspect ratio. The distinction matters for a letterboxed source, and the old wording invited callers to
135
+ write letterbox compensation that would have been wrong.
136
+
10
137
  ## 0.4.0
11
138
 
12
139
  ### Added
package/FORK.md CHANGED
@@ -20,12 +20,14 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
20
20
 
21
21
  | File | Lines | Purpose |
22
22
  | --- | --- | --- |
23
- | `ios/Records/SubtitleSource.swift` | 41 | Record for one sideloaded subtitle; `isSrt` detection; `bottomOffset`, which lives here rather than on `SubtitleStyle` because the composition is built from a `VideoSource` alone, before any `VideoView` has supplied a style. |
23
+ | `ios/Records/SubtitleSource.swift` | 61 | Record for one sideloaded subtitle; `resolvedFormat` / `isSrt` / `isAss` detection; `bottomOffset`, which lives here rather than on `SubtitleStyle` because the composition is built from a `VideoSource` alone, before any `VideoView` has supplied a style. |
24
24
  | `ios/Records/SubtitleStyle.swift` | 118 | Record for caption styling; hex→ARGB parsing; builds `[AVTextStyleRule]`. |
25
- | `ios/VideoPlayerSubtitleSideload.swift` | 257 | Builds the `AVMutableComposition`, downloads remote subs, SRT→WebVTT converter, and `applyLinePosition`, which writes a WebVTT `line:` cue setting per cue for `bottomOffset` — the only way to position captions AVFoundation renders itself. Cue timing lines are matched by **timestamp regex, not by searching for `-->`**: subtitle text may contain an arrow, and appending a cue setting to it prints the setting on screen. The temp-file hash includes `bottomOffset`, or a changed offset would reuse the rendition written for the previous one. |
26
- | `android/…/records/SubtitleSource.kt` | 54 | Record → `MediaItem.SubtitleConfiguration` + MIME resolution. |
27
- | `android/…/records/SubtitleStyle.kt` | 144 | Record → `CaptionStyleCompat`; hex colour parsing; font-family resolution (`res/font` → `ReactFontManager`); the `applyEmbeddedStyles` flag, which `SubtitleUtils` reads directly rather than `toCaptionStyle`. |
28
- | `android/…/utils/StackingSubtitleParser.kt` | 300 | `SubtitleParser` for SubRip that segments the cue timeline and merges simultaneous cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip to it. Assigns each cue a vertical slot it keeps for its whole time on screen — a finished cue's line is held blank (`\u200B`) so the cues above it do not drop. Merges through a `SpannableStringBuilder` so cue spans survive. |
25
+ | `ios/VideoPlayerSubtitleSideload.swift` | 282 | Builds the `AVMutableComposition`, downloads remote subs, SRT→WebVTT converter, and `applyLinePosition`, which writes a WebVTT `line:N%,end` cue setting per cue for `bottomOffset` — the only way to position captions AVFoundation renders itself. **The trailing `,end` is the line alignment component, not a `line-align` setting** (there is no such setting; the six are `vertical`/`line`/`position`/`size`/`align`/`region`), and it is what anchors the percentage to the bottom edge, matching Android. A parser predating it drops the whole `line` setting, so there is no graceful degradation — verify on device. Cue timing lines are matched by **timestamp regex, not by searching for `-->`**: subtitle text may contain an arrow, and appending a cue setting to it prints the setting on screen. The temp-file hash includes `bottomOffset` *and* a `formatVersion`, joined on **NUL** — the offset so a changed value does not reuse the previous rendition, the version so an upgrade that changes the written form does not reuse the previous release's, and NUL because a hyphen occurs inside both BCP-47 tags and URLs and let two subtitles collide. |
26
+ | `ios/SubtitleConverterText.swift` | 566 | ASS/SSA → WebVTT, the reworked SubRip → WebVTT cue-block parser, WebVTT sanitising, and the non-UTF-8 decode ladder. Header fields are read from the script's own `Format:` lines, never assumed: encoders reorder them, and reading the Text column one position off yields a file that still parses and is entirely wrong. Override blocks are walked by a **scanner, not a list of regexes**, and the tag name is read as a run of letters — that is what stops `\c` from eating `\clip` and `\b` from eating `\bord`, and what makes `\p` require its digit so `\pos` and `\pbo` can never be mistaken for a vector drawing. Text is escaped **before** markup is inserted (`&` then `<` then `>`), which also turns a dialogue `-->` into `--&gt;` so WebVTT cannot read a text line as a cue boundary. |
27
+ | `android/…/utils/SubtitleSpanFilter.kt` | 135 | Strips colour, size, family and weight from every cue of every format, keeping italic/underline/strikethrough and all positioning, plus `FilteringSubtitleParser` which applies it to whatever a delegate emits. **Load-bearing fact, verified against Media3 1.9.0 bytecode:** `SubtitleViewUtils.removeAllEmbeddedStyling` touches only `clearWindowColor`, `getText`/`setText` and `setTextSize` — never `line`, `position` or `textAlignment`. That is why the filter can live in the parser and the view flag can stay on; if an upstream sync changes it, this whole design has to be revisited. Uses an **allowlist**, because a denylist misses `TextAppearanceSpan` (colour + size + family in one span). `StyleSpan` needs a rewrite rather than a keep/drop: `BOLD_ITALIC` has to come back as plain `ITALIC` over the same range. |
28
+ | `android/…/records/SubtitleSource.kt` | 63 | Record → `MediaItem.SubtitleConfiguration` + MIME resolution. |
29
+ | `android/…/records/SubtitleStyle.kt` | 152 | Record → `CaptionStyleCompat`; hex colour parsing; font-family resolution (`res/font` → `ReactFontManager`); the `applyEmbeddedStyles` flag, which `SubtitleUtils` reads directly rather than `toCaptionStyle` and which defaults to `true` since 0.5.0. |
30
+ | `android/…/utils/StackingSubtitleParser.kt` | 308 | `SubtitleParser` for SubRip that segments the cue timeline and merges simultaneous cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip to it and wraps every parser in `FilteringSubtitleParser`. SSA/ASS is deliberately not stacked: `SsaParser` sets both `line` and `position` on every cue, so they would all take the passthrough branch anyway. Assigns each cue a vertical slot it keeps for its whole time on screen — a finished cue's line is held blank (`\u200B`) so the cues above it do not drop. Merges through a `SpannableStringBuilder` so cue spans survive. |
29
31
  | `android/…/utils/RoundedSubtitleBackground.kt` | 352 | Draws `subtitleStyle.backgroundRadius` as rounded boxes, which Media3 cannot do at all: it paints `backgroundColor` as a framework `BackgroundColorSpan` and `windowColor` with `canvas.drawRect`, neither takes a radius, and `SubtitlePainter`/`CanvasSubtitleOutput` are package-private final while `SubtitleView` is final. A second `SubtitleView` is inserted behind `PlayerView`'s own, fed from `Player.Listener.onCues`, drawing a `LineBackgroundSpan` per line. **Its `EDGE_TYPE_NONE` is load-bearing** — `SubtitlePainter` copies the cue text for an edge pass and draws both layouts when the edge type is outline/raised/depressed, which would paint the box twice and darken a semi-transparent colour. State hangs off the view's `tag`, never a map in the companion object; see the `find` KDoc for why even a `WeakHashMap` leaks here. |
30
32
  | `FORK.md`, `LICENSE` | — | This guide; MIT licence (upstream copyright preserved). |
31
33
  | `eslint.config.js` | 4 | Verbatim copy of `expo-module-scripts/templates/eslint.config.js`. **Recreate it if a sync drops it** — `expo-module configure` treats it as an *optional* template and only syncs it when it already exists, so without this file `npm run lint` dies with "ESLint couldn't find an eslint.config.js". |
@@ -36,8 +38,8 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
36
38
 
37
39
  | File | Δ | What to re-apply |
38
40
  | --- | --- | --- |
39
- | `src/VideoPlayer.types.ts` | +79 | `subtitleTracks?: SubtitleSource[]` on `VideoSourceObject`; the exported `SubtitleSource` type, including `bottomOffset`. |
40
- | `src/VideoView.types.ts` | +129 | `subtitleStyle?: SubtitleStyle` on `VideoViewProps`; `SubtitleStyle` + `SubtitleEdgeType` types. |
41
+ | `src/VideoPlayer.types.ts` | +92 | `subtitleTracks?: SubtitleSource[]` on `VideoSourceObject`; the exported `SubtitleSource` type, including `bottomOffset` and the `'vtt' | 'srt' | 'ass' | 'ssa'` format union. |
42
+ | `src/VideoView.types.ts` | +123 | `subtitleStyle?: SubtitleStyle` on `VideoViewProps`; `SubtitleStyle` + `SubtitleEdgeType` types. |
41
43
  | `src/index.ts` | +2 | Export `SubtitleStyle`, `SubtitleEdgeType` (`SubtitleSource` rides on `export type *`). |
42
44
  | `src/VideoPlayer.web.tsx` | +5 / −1 | Hoist `JSON.stringify(source)` out of the `useMemo` dependency list into a `sourceKey` const. No behaviour change — it was already recomputed every render — but `react-hooks/use-memo` rejects non-simple expressions in a dependency array, and it was the single lint **error** in the package. |
43
45
  | `src/ts-declarations/react-native-assets.d.ts` | +22 / −1 | Replace upstream's `../../../expo-asset/…` reference (monorepo-only) with standalone `declare module` shims. **Always needed outside the Expo monorepo.** |
@@ -47,7 +49,7 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
47
49
  | `ios/VideoView.swift` | +8 | `subtitleStyle` property; forward it when `player` is (re)assigned. |
48
50
  | `ios/VideoModule.swift` | +4 | `Prop("subtitleStyle")`. |
49
51
  | `android/…/records/VideoSource.kt` | +9 / −2 | `subtitleTracks` field; `setSubtitleConfigurations(…)` in `toMediaItem()`; subtitle key in `toMediaId()`. |
50
- | `android/…/utils/SubtitleUtils.kt` | +92 / −21 | `configureSubtitleView(…, customStyle)` overload and a `styleProvider` on the captioning listener. Keeps the original behaviour when no custom style is set. Passes `context` to `toCaptionStyle` for font lookup. Drives `setApplyEmbeddedStyles` from `customStyle.applyEmbeddedStyles` (upstream hardcodes `false`). |
52
+ | `android/…/utils/SubtitleUtils.kt` | +92 / −21 | `configureSubtitleView(…, customStyle)` overload and a `styleProvider` on the captioning listener. Keeps the original behaviour when no custom style is set. Passes `context` to `toCaptionStyle` for font lookup. Drives `setApplyEmbeddedStyles` from `customStyle.applyEmbeddedStyles`, defaulting to `true` (upstream hardcodes `false`). The resolved value is shared with `RoundedSubtitleBackground`: `StyleSpan` is a `MetricAffectingSpan`, so italic measures wider and a disagreement draws the box too narrow. |
51
53
  | `android/…/VideoView.kt` | +16 / −5 | `subtitleStyle` property; pass it at all 4 `configureSubtitleView` call sites + the captioning listener. |
52
54
  | `android/…/VideoModule.kt` | +4 | `Prop("subtitleStyle")`. |
53
55
  | `android/…/utils/DataSourceUtils.kt` | +5 | `setSubtitleParserFactory(ExpoVideoSubtitleParserFactory())` on the `DefaultMediaSourceFactory`. Note this file declares `package expo.modules.video` despite living in `utils/`, so the factory needs an explicit import. |
@@ -182,7 +184,9 @@ npm pack --dry-run # expect build/, plugin/build/, LICENSE, native sources
182
184
  # NO dev config (.prettierrc, eslint.config.js, *.tsbuildinfo) — see §1
183
185
  ```
184
186
 
185
- The file count is a blunt but effective regression check: **254 files** as of 0.2.2. If a change moves
187
+ The file count is a blunt but effective regression check: **257 files** as of 0.5.0 (255 at 0.4.1, plus
188
+ `ios/SubtitleConverterText.swift` and `android/…/utils/SubtitleSpanFilter.kt`; 254 at 0.2.2, plus
189
+ `RoundedSubtitleBackground.kt` in 0.3.0). If a change moves
186
190
  it, diff the list against the previous release and confirm every difference is intended —
187
191
  `npm pack --dry-run --json` gives a machine-readable list. Both directions matter: an unexpected
188
192
  *addition* means dev clutter leaked in, an unexpected *removal* means a consumer is missing something.
@@ -194,6 +198,60 @@ Neither is created by `expo-module configure`, so a sync that loses one breaks t
194
198
  that reads like a tooling bug. A `prettier/prettier` warning after a sync is far more likely to mean
195
199
  the config went missing than that the code is genuinely misformatted — check that first.
196
200
 
201
+ ### iOS — running the subtitle converter without a host app
202
+
203
+ The converter imports only `Foundation`, so it compiles and runs without a host app. There is a
204
+ script for it:
205
+
206
+ ```bash
207
+ ./scripts/verify-subtitle-converter.sh
208
+ ```
209
+
210
+ It extracts the shipped source rather than copying it — `awk` for the `SubtitleConverter` enum out of
211
+ `ios/VideoPlayerSubtitleSideload.swift`, plus `ios/SubtitleConverterText.swift` with its
212
+ `ExpoModulesCore` import stripped and `log` stubbed — so the assertions cannot drift from what ships.
213
+ 87 of them live in `scripts/subtitle-converter-checks.swift`.
214
+
215
+ **`scripts/` must stay out of `ios/`.** The podspec globs `ios/**/*.{h,m,swift}`, so a checks file
216
+ with top-level code placed under `ios/` would be compiled into every consumer's pod and fail their
217
+ build. It is also listed in `.npmignore`.
218
+
219
+ This is worth exercising on every change, because everything it covers is a pure string transform
220
+ whose failure mode is a silently malformed cue rather than a crash: a cue setting appended to a
221
+ *text* line prints on screen, a percentage outside `0`–`100` can cost the whole cue, and a
222
+ mis-scanned override block can eat the dialogue around it.
223
+
224
+ Cases the harness keeps covered: all nine `\an` values and the legacy `\a`; `\pos` scaling against
225
+ `PlayResX`/`PlayResY`, including the 4:3 derivation when only one axis is declared; the full
226
+ drop-list of appearance tags; longest-match (`\clip` is not `\c`, `\bord` is not `\b`); whole-event
227
+ drops for `Comment:`, `{\p1}` drawings and tags-only lines; escaping order, including a dialogue
228
+ `-->`; commas inside the ASS Text column; a reordered `[Events] Format:` line; the SubRip path with a
229
+ leaked `{\an8}`, `<font color>` and `<b>`; WebVTT `<c.…>` and STYLE-block sanitising; the
230
+ `applyLinePosition` interaction for positioned, bottom-centre and side-only cues; and the encoding
231
+ ladder.
232
+
233
+ **A passing harness does not mean the cue renders where you asked.** It checks the bytes written, not
234
+ whether AVFoundation honours them. That is the part of this package with no automated coverage at all
235
+ — confirm it against a real player with a *two-line* cue, which is the case the alignment component
236
+ exists for.
237
+
238
+ Baseline: as of 0.4.1, AVFoundation honours `line:` **and** the `,end` alignment component on iOS 26.5,
239
+ placing the caption's bottom edge at the requested percentage.
240
+
241
+ As of 0.5.0 the ASS mapping newly relies on **`position:`, `align:` and the `,center` line alignment,
242
+ and none of the three is confirmed on device.** CoreMedia exposes a matching text-markup attribute for
243
+ each WebVTT cue setting, which is the reason to expect them to work, but there is no attribute for
244
+ line-align or position-align — AVFoundation resolves those internally, and internal resolution is
245
+ exactly the kind of thing that can be partial. Verify `\an5` (`,center`) and `\an7`
246
+ (`position:` + `align:`) on the oldest supported OS as well as the newest; the deployment target is
247
+ 16.4. If they turn out unsupported, the fallback is to keep the vertical mapping and drop the
248
+ horizontal one, which puts `\an7`/`\an8`/`\an9` all at top-centre and still delivers most of the
249
+ value.
250
+
251
+ Re-confirm all of this after an upstream sync or when adding support for an older deployment target —
252
+ the failure is silent, and for `line:` it is total rather than partial (a parser that rejects the
253
+ alignment component drops the whole setting).
254
+
197
255
  ### Android — compiling the parser without a host app
198
256
 
199
257
  `utils/StackingSubtitleParser.kt` imports only `android.text.*` and `androidx.media3.*`, so it
package/README.md CHANGED
@@ -60,6 +60,7 @@ const player = useVideoPlayer({
60
60
  subtitleTracks: [
61
61
  { uri: 'https://cdn.example.com/en.vtt', language: 'en', label: 'English', default: true },
62
62
  { uri: 'https://cdn.example.com/id.srt', language: 'id', label: 'Indonesia', format: 'srt' },
63
+ { uri: 'https://cdn.example.com/signs.ass', language: 'ja', label: 'Signs', format: 'ass' },
63
64
  ],
64
65
  });
65
66
 
@@ -67,12 +68,14 @@ const player = useVideoPlayer({
67
68
  player.subtitleTrack = player.availableSubtitleTracks[0];
68
69
  ```
69
70
 
70
- `SubtitleSource` fields: `uri` (local `file://` or remote `http(s)://`), `language` (BCP-47 / ISO 639), optional `label`, optional `format` (`'vtt' | 'srt'`, inferred from the extension when omitted), optional `default`, and optional `bottomOffset` (iOS — see [Moving subtitles off burned-in captions](#moving-subtitles-off-burned-in-captions)).
71
+ `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](#moving-subtitles-off-burned-in-captions)).
72
+
73
+ 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.
71
74
 
72
75
  | | Android | iOS |
73
76
  | --- | --- | --- |
74
77
  | Mechanism | `MediaItem.SubtitleConfiguration` (Media3 merges into a `MergingMediaSource`) | `AVMutableComposition` (copies the video/audio tracks and inserts a `.text` track) |
75
- | Formats | VTT, SRT, TTML natively | VTT natively; **SRT is auto-converted to VTT**; remote files are **downloaded to a local cache first** |
78
+ | 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** |
76
79
 
77
80
  > **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.
78
81
 
@@ -138,13 +141,28 @@ takes effect the next time the source loads**.
138
141
  | Where | `subtitleStyle.bottomOffset` | `subtitleTracks[].bottomOffset` |
139
142
  | Applies to | every track, embedded included | sideloaded tracks only |
140
143
  | Updates | live | on next source load |
141
- | Anchors | the bottom of the subtitle block | the top of it, so a two-line cue grows downward |
144
+ | Anchors | the bottom of the subtitle block | the bottom of the subtitle block |
142
145
  | Cues that position themselves | left alone | left alone |
143
146
 
144
147
  That last row is worth knowing: subtitles carrying their own placement — SubRip `{\anN}` tags, WebVTT
145
148
  cues with a `line:` setting — were positioned deliberately by whoever wrote them, so neither platform
146
149
  moves them.
147
150
 
151
+ **ASS/SSA is the case where the two platforms diverge.** Media3's `SsaParser` gives *every* cue an
152
+ explicit line, so `subtitleStyle.bottomOffset` moves nothing on an ASS track on Android. On iOS the
153
+ converter deliberately emits no `line:` for bottom-centre cues (`\an2`, the default for dialogue),
154
+ so ordinary dialogue still shifts while genuine `\an8` signs stay put. Fixing the Android side means
155
+ a custom SSA parser that can tell a style default from a real `\pos`; it is not in this release.
156
+
157
+ The iOS cue setting is written as `line:N%,end`. The trailing `,end` is WebVTT's line alignment
158
+ component, and it is what makes the percentage refer to the bottom edge of the cue box — so the two
159
+ platforms anchor the same way and one value is correct on both. AVFoundation honours both parts,
160
+ confirmed on iOS 26.5.
161
+
162
+ For older targets, note that a WebVTT parser predating that component discards the whole `line` setting
163
+ rather than ignoring the component, which would leave captions unpositioned; there is no form that
164
+ degrades more gently, since both behaviours come from the same setting.
165
+
148
166
  ## Styling subtitles
149
167
 
150
168
  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`.
@@ -163,7 +181,7 @@ Pass a `subtitleStyle` prop to `VideoView`. It applies to whichever subtitle tra
163
181
  edgeType: 'outline', // 'none' | 'outline' | 'dropShadow' | 'raised' | 'depressed'
164
182
  edgeColor: '#000000',
165
183
  bottomOffset: 0.1, // Android only — fraction of the view height (0–1)
166
- applyEmbeddedStyles: false, // Android only — see "Inline formatting from the track"
184
+ applyEmbeddedStyles: true, // Android only — keeps the track's italic/underline; default true
167
185
  }}
168
186
  />
169
187
  ```
@@ -198,21 +216,34 @@ the region the caption occupies, the background is the box that hugs the text.
198
216
 
199
217
  ### Inline formatting from the track
200
218
 
201
- Subtitle files carry formatting of their own — `<b>`, `<i>`, `<u>` and `<font color>` tags in `SRT`, and
202
- richer per-cue styling in embedded `WebVTT`/`TTML` tracks. **Android strips all of it by default**, so
203
- `subtitleStyle` is the only thing deciding how captions look. Opt in to keep it:
219
+ Subtitle files carry formatting of their own: `<b>`, `<i>`, `<u>` and `<font color>` in `SRT`, `<c.yellow>`
220
+ and `<b>` in `WebVTT`, and a whole styling language in `ASS`. The module splits it in two.
221
+
222
+ **Colour, font size, font family and weight are always discarded, on both platforms.** They are removed
223
+ while the file is being parsed, before any view exists, so `subtitleStyle` is the single thing deciding
224
+ how captions look. There is no way to opt back into a track's own colours — a subtitle shipping yellow
225
+ 72pt bold text cannot override the size your user picked.
226
+
227
+ **Italic, underline and strikethrough are kept**, and `applyEmbeddedStyles: false` is the blunt switch
228
+ that drops them too:
204
229
 
205
230
  ```tsx
206
- <VideoView player={player} subtitleStyle={{ textColor: '#FFFFFF', applyEmbeddedStyles: true }} />
231
+ <VideoView player={player} subtitleStyle={{ textColor: '#FFFFFF', applyEmbeddedStyles: false }} />
207
232
  ```
208
233
 
209
- The rest of `subtitleStyle` still applies underneath, and `fontSize` keeps overriding any size the track
210
- asks for. Only the properties a cue explicitly sets change — an unstyled cue looks identical either way.
211
- The trade-off is that a track specifying its own colors overrides `textColor` and `windowColor` for those
212
- cues, so leave this off when the captions must match your UI exactly.
234
+ A track's own placement is unaffected either way, so an `\an8` sign still sits at the top.
235
+
236
+ Two asymmetries worth knowing:
237
+
238
+ | | Android | iOS |
239
+ | --- | --- | --- |
240
+ | Strikethrough | renders | **cannot render** — WebVTT has no strikethrough tag and CoreMedia has no attribute for one |
241
+ | Italic/underline in ASS | from the `[V4+ Styles]` `Style:` line only | from the style line **and** inline `{\i1}` |
242
+ | `applyEmbeddedStyles` | honoured | ignored; italic and underline always render |
213
243
 
214
- On iOS this is not configurable: `AVPlayerItem.textStyleRules` composes with whatever the WebVTT cue
215
- declares, so a track's inline formatting always renders.
244
+ The Android ASS limitation is Media3's: `SsaStyle.Overrides` parses only `\an`, `\pos` and `\move`,
245
+ then `stripStyleOverrides` deletes every remaining `{...}` block, so an inline `{\i1}` never reaches
246
+ the renderer.
216
247
 
217
248
  ### Fonts
218
249
 
@@ -37,14 +37,24 @@ class SubtitleSource(
37
37
  return builder.build()
38
38
  }
39
39
 
40
+ /**
41
+ * ASS and SSA both map to [MimeTypes.TEXT_SSA] ("text/x-ssa"), which Media3's
42
+ * `DefaultSubtitleParserFactory` already accepts, so no parser of our own is
43
+ * needed for them. The `else` branch defaults to WebVTT, which means an
44
+ * unrecognised extension decodes to zero cues rather than erroring: the
45
+ * caller should pass [format] whenever it knows, and a Directus asset URL
46
+ * (`/assets/<uuid>`) carries no extension to fall back on.
47
+ */
40
48
  private fun resolveMimeType(uri: Uri): String {
41
49
  return when (format?.lowercase()) {
42
- "vtt" -> MimeTypes.TEXT_VTT
43
- "srt" -> MimeTypes.APPLICATION_SUBRIP
50
+ "vtt", "webvtt" -> MimeTypes.TEXT_VTT
51
+ "srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
52
+ "ass", "ssa" -> MimeTypes.TEXT_SSA
44
53
  else -> {
45
54
  val path = uri.toString().substringBefore('?').lowercase()
46
55
  when {
47
56
  path.endsWith(".srt") -> MimeTypes.APPLICATION_SUBRIP
57
+ path.endsWith(".ass") || path.endsWith(".ssa") -> MimeTypes.TEXT_SSA
48
58
  path.endsWith(".ttml") || path.endsWith(".dfxp") -> MimeTypes.APPLICATION_TTML
49
59
  else -> MimeTypes.TEXT_VTT
50
60
  }
@@ -35,11 +35,16 @@ class SubtitleStyle(
35
35
  */
36
36
  @Field val backgroundRadius: Float? = null,
37
37
  /**
38
- * Whether the inline formatting carried by the subtitle track itself is kept. Consumed by
39
- * [expo.modules.video.utils.SubtitleUtils.configureSubtitleView] rather than [toCaptionStyle],
40
- * because it maps to a [androidx.media3.ui.SubtitleView] flag and not to a caption style property.
38
+ * Whether the italic, underline and strikethrough carried by the subtitle track itself are kept.
39
+ * Consumed by [expo.modules.video.utils.SubtitleUtils.configureSubtitleView] rather than
40
+ * [toCaptionStyle], because it maps to a [androidx.media3.ui.SubtitleView] flag and not to a
41
+ * caption style property.
42
+ *
43
+ * Colour, font size, font family and weight are never taken from the track regardless of this
44
+ * flag: [expo.modules.video.utils.SubtitleSpanFilter] strips them in the parser, before any view
45
+ * exists. That is what makes `true` a safe default here, which it was not before.
41
46
  */
42
- @Field val applyEmbeddedStyles: Boolean = false
47
+ @Field val applyEmbeddedStyles: Boolean = true
43
48
  ) : Record, Serializable {
44
49
  /**
45
50
  * Builds a [CaptionStyleCompat], falling back to [base] for any unspecified property so that
@@ -275,8 +275,16 @@ class StackingSubripParser : SubtitleParser {
275
275
  }
276
276
 
277
277
  /**
278
- * Routes SubRip through [StackingSubripParser] so simultaneous cues stack, and delegates every other
279
- * subtitle format to Media3's default handling.
278
+ * Routes SubRip through [StackingSubripParser] so simultaneous cues stack, delegates every other
279
+ * subtitle format to Media3's default handling, and wraps the result in [FilteringSubtitleParser] so
280
+ * the appearance a track asks for never overrides the app's own subtitle style.
281
+ *
282
+ * SSA and ASS are deliberately NOT stacked. `SsaParser` gives every cue both a `line` and a
283
+ * `position`, so [StackingSubripParser.hasExplicitPosition] would send all of them down the
284
+ * passthrough branch anyway, and its merge deliberately leaves `line` unset, which would fight
285
+ * `\an` and `\pos`. The consequence is real and known: Media3 does no collision avoidance between
286
+ * positioned cues, so two simultaneous `\an2` dialogue lines overlap on Android where libass would
287
+ * stack them.
280
288
  */
281
289
  @OptIn(UnstableApi::class)
282
290
  class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
@@ -293,7 +301,9 @@ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
293
301
  }
294
302
 
295
303
  override fun create(format: Format): SubtitleParser =
296
- if (format.isSubrip()) StackingSubripParser() else defaultFactory.create(format)
304
+ FilteringSubtitleParser(
305
+ if (format.isSubrip()) StackingSubripParser() else defaultFactory.create(format)
306
+ )
297
307
 
298
308
  private fun Format.isSubrip(): Boolean =
299
309
  MimeTypes.APPLICATION_SUBRIP.equals(sampleMimeType, ignoreCase = true)
@@ -0,0 +1,135 @@
1
+ package expo.modules.video.utils
2
+
3
+ import android.graphics.Typeface
4
+ import android.text.Spannable
5
+ import android.text.SpannableString
6
+ import android.text.Spanned
7
+ import android.text.style.AlignmentSpan
8
+ import android.text.style.StrikethroughSpan
9
+ import android.text.style.StyleSpan
10
+ import android.text.style.UnderlineSpan
11
+ import androidx.annotation.OptIn
12
+ import androidx.media3.common.text.Cue
13
+ import androidx.media3.common.text.LanguageFeatureSpan
14
+ import androidx.media3.common.util.Consumer
15
+ import androidx.media3.common.util.UnstableApi
16
+ import androidx.media3.extractor.text.CuesWithTiming
17
+ import androidx.media3.extractor.text.SubtitleParser
18
+
19
+ /**
20
+ * Drops the appearance a subtitle file asks for while keeping its layout.
21
+ *
22
+ * Colour, font size, font family and weight are the app's to decide: only the
23
+ * app knows the viewer's appearance settings, so a track that ships yellow
24
+ * 72pt bold text must not win over them. Everything about *where* and *how the
25
+ * text reads* stays: italic, underline, strikethrough, alignment, and every
26
+ * positioning field on the cue.
27
+ *
28
+ * This has to live in the parser. It is the only place that sees a cue before
29
+ * Media3 hands it to a `SubtitleView` we do not own, and the parser factory is
30
+ * installed on the media source (see [DataSourceUtils.buildMediaSourceFactory])
31
+ * long before any `VideoView` has supplied a style.
32
+ *
33
+ * The complementary fact, verified against Media3 1.9.0: `SubtitleView`'s own
34
+ * `applyEmbeddedStyles` flag is all-or-nothing and, when off,
35
+ * `SubtitleViewUtils.removeAllEmbeddedStyling` strips *every* span including
36
+ * italic. It never touches `line`, `position` or `textAlignment`, which is why
37
+ * filtering here and leaving that flag on is the combination that works.
38
+ */
39
+ @OptIn(UnstableApi::class)
40
+ internal object SubtitleSpanFilter {
41
+ /**
42
+ * An allowlist, not a denylist.
43
+ *
44
+ * A denylist would miss `TextAppearanceSpan`, which carries colour, size and
45
+ * family in a single span and is what `Html.fromHtml` emits on some API
46
+ * levels, and it stays wrong for whatever span type a future Media3 parser
47
+ * introduces. Dropping an unknown span is the safe direction.
48
+ */
49
+ private fun isKept(span: Any): Boolean = when (span) {
50
+ is UnderlineSpan, is StrikethroughSpan, is AlignmentSpan -> true
51
+ // Ruby, text emphasis and vertical-context spans are layout and language
52
+ // features. Media3 exempts them from its own stripping for the same reason.
53
+ is LanguageFeatureSpan -> true
54
+ // Bold goes, italic stays. BOLD_ITALIC needs the rewrite below.
55
+ is StyleSpan -> span.style == Typeface.ITALIC
56
+ else -> false
57
+ }
58
+
59
+ fun filter(cue: Cue): Cue {
60
+ val text = cue.text
61
+ val hasSpans = text is Spanned
62
+ val needsWork = hasSpans || cue.windowColorSet || cue.textSize != Cue.DIMEN_UNSET
63
+ if (!needsWork) return cue
64
+
65
+ val builder = cue.buildUpon()
66
+
67
+ if (hasSpans) {
68
+ val spannable = SpannableString(text)
69
+ // Snapshot the spans first: the loop both removes and adds, and a live
70
+ // query would see the replacements it just made.
71
+ for (span in spannable.getSpans(0, spannable.length, Any::class.java)) {
72
+ if (isKept(span)) continue
73
+
74
+ val start = spannable.getSpanStart(span)
75
+ val end = spannable.getSpanEnd(span)
76
+ val flags = spannable.getSpanFlags(span)
77
+ spannable.removeSpan(span)
78
+
79
+ // StyleSpan is one class carrying four states, so bold and italic
80
+ // cannot be separated by a keep/drop decision. BOLD_ITALIC has to come
81
+ // back as plain italic over the same range.
82
+ if (span is StyleSpan && span.style == Typeface.BOLD_ITALIC) {
83
+ spannable.setSpan(StyleSpan(Typeface.ITALIC), start, end, flags)
84
+ }
85
+ }
86
+ builder.setText(spannable)
87
+ }
88
+
89
+ // A cue's own window colour is the track's, and `subtitleStyle.windowColor`
90
+ // owns that. Sizes go the same way: `setFixedTextSize` is the single source
91
+ // of truth, and clearing it here keeps the rounded-background view (which
92
+ // reads raw player cues) measuring the same glyphs as the front one.
93
+ builder.clearWindowColor()
94
+ builder.setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET)
95
+
96
+ return builder.build()
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Applies [SubtitleSpanFilter] to every cue a delegate parser emits.
102
+ *
103
+ * Wrapping is deliberately the outermost layer so it also covers the merged
104
+ * block [StackingSubripParser] produces, and so every format gets the same
105
+ * policy for free.
106
+ */
107
+ @OptIn(UnstableApi::class)
108
+ internal class FilteringSubtitleParser(
109
+ private val delegate: SubtitleParser
110
+ ) : SubtitleParser {
111
+ // Forwarded verbatim. Getting this wrong silently changes whether consecutive
112
+ // CuesWithTiming replace or merge: SsaParser is MERGE, StackingSubripParser
113
+ // is REPLACE.
114
+ override fun getCueReplacementBehavior(): Int = delegate.cueReplacementBehavior
115
+
116
+ override fun reset() = delegate.reset()
117
+
118
+ override fun parse(
119
+ data: ByteArray,
120
+ offset: Int,
121
+ length: Int,
122
+ outputOptions: SubtitleParser.OutputOptions,
123
+ output: Consumer<CuesWithTiming>
124
+ ) {
125
+ delegate.parse(data, offset, length, outputOptions) { group ->
126
+ output.accept(
127
+ CuesWithTiming(
128
+ group.cues.map(SubtitleSpanFilter::filter),
129
+ group.startTimeUs,
130
+ group.durationUs
131
+ )
132
+ )
133
+ }
134
+ }
135
+ }
@@ -20,12 +20,18 @@ object SubtitleUtils {
20
20
  * the app full control over text color, background, window color, edge, font, size, and position.
21
21
  */
22
22
  fun configureSubtitleView(playerView: PlayerView, context: Context, customStyle: SubtitleStyle? = null) {
23
+ // Resolved once and shared with the rounded-background view below. The two must agree: a
24
+ // `StyleSpan` is a `MetricAffectingSpan`, so italic text measures wider, and a disagreement
25
+ // draws the box too narrow for the glyphs in front of it.
26
+ val applyEmbeddedStyles = customStyle?.applyEmbeddedStyles ?: true
27
+
23
28
  playerView.subtitleView?.apply {
24
- // Off by default so the app's style fully controls the appearance. `SubtitleView` implements
25
- // this by stripping every span from each cue (`SubtitleViewUtils.removeAllEmbeddedStyling`),
26
- // which also erases the formatting `SubripParser` produces for SubRip's <b>/<i>/<u>/<font>
27
- // tags — so a track's inline formatting only survives when the app opts in here.
28
- setApplyEmbeddedStyles(customStyle?.applyEmbeddedStyles == true)
29
+ // On by default now that the parser has already removed colour, size and weight
30
+ // (`SubtitleSpanFilter`), so what is left to keep is only italic, underline and
31
+ // strikethrough. Turning this off is the blunt option: `SubtitleView` implements it via
32
+ // `SubtitleViewUtils.removeAllEmbeddedStyling`, which strips every remaining span. It does
33
+ // not touch `line`/`position`/`textAlignment`, so a track's own placement survives either way.
34
+ setApplyEmbeddedStyles(applyEmbeddedStyles)
29
35
  // Sizes stay app-controlled either way: `setFixedTextSize` below is the single source of truth.
30
36
  setApplyEmbeddedFontSizes(false)
31
37
 
@@ -71,7 +77,7 @@ object SubtitleUtils {
71
77
  cornerRadiusDp,
72
78
  textSizeSp,
73
79
  bottomPaddingFraction,
74
- customStyle.applyEmbeddedStyles
80
+ applyEmbeddedStyles
75
81
  )
76
82
  } else {
77
83
  setStyle(resolved)