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 +127 -0
- package/FORK.md +67 -9
- package/README.md +45 -14
- package/android/src/main/java/expo/modules/video/records/SubtitleSource.kt +12 -2
- package/android/src/main/java/expo/modules/video/records/SubtitleStyle.kt +9 -4
- package/android/src/main/java/expo/modules/video/utils/StackingSubtitleParser.kt +13 -3
- package/android/src/main/java/expo/modules/video/utils/SubtitleSpanFilter.kt +135 -0
- package/android/src/main/java/expo/modules/video/utils/SubtitleUtils.kt +12 -6
- package/build/VideoPlayer.types.d.ts +18 -5
- package/build/VideoPlayer.types.d.ts.map +1 -1
- package/build/VideoPlayer.types.js.map +1 -1
- package/build/VideoView.types.d.ts +15 -13
- package/build/VideoView.types.d.ts.map +1 -1
- package/build/VideoView.types.js.map +1 -1
- package/ios/Records/SubtitleSource.swift +26 -5
- package/ios/Records/SubtitleStyle.swift +8 -0
- package/ios/SubtitleConverterText.swift +752 -0
- package/ios/VideoPlayerSubtitleSideload.swift +64 -48
- package/package.json +1 -1
- package/src/VideoPlayer.types.ts +18 -5
- package/src/VideoView.types.ts +15 -13
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` |
|
|
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` |
|
|
26
|
-
| `
|
|
27
|
-
| `android/…/
|
|
28
|
-
| `android/…/
|
|
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 `-->` 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` | +
|
|
40
|
-
| `src/VideoView.types.ts` | +
|
|
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: **
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
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:
|
|
231
|
+
<VideoView player={player} subtitleStyle={{ textColor: '#FFFFFF', applyEmbeddedStyles: false }} />
|
|
207
232
|
```
|
|
208
233
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
|
39
|
-
* [expo.modules.video.utils.SubtitleUtils.configureSubtitleView] rather than
|
|
40
|
-
* because it maps to a [androidx.media3.ui.SubtitleView] flag and not to a
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
80
|
+
applyEmbeddedStyles
|
|
75
81
|
)
|
|
76
82
|
} else {
|
|
77
83
|
setStyle(resolved)
|