expo-video-subtitle 0.1.3 → 0.3.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,111 @@ 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.3.0
11
+
12
+ ### Added
13
+
14
+ - **Android:** `subtitleStyle.backgroundRadius` rounds the corners of the `backgroundColor` box, in dp.
15
+ One box is drawn per rendered line, so a caption that wraps gets two rounded boxes stacked on each
16
+ other, the way VLC and most desktop players draw them. It has no effect unless `backgroundColor` is
17
+ set to something that is not fully transparent, and `windowColor` is deliberately left rectangular —
18
+ the window is the region the caption occupies, the background is the box that hugs the text.
19
+
20
+ Media3 offers nothing for this. `SubtitlePainter` renders `backgroundColor` as an Android
21
+ `BackgroundColorSpan`, which the framework paints with `canvas.drawRect`, and it paints `windowColor`
22
+ with a `canvas.drawRect` of its own; neither takes a radius, and `SubtitlePainter` and
23
+ `CanvasSubtitleOutput` are package-private and final while `SubtitleView` is final, so none of it can
24
+ be subclassed or replaced. The background is therefore drawn by a second subtitle view inserted
25
+ behind the one `PlayerView` owns, fed the same cues and configured with the same typeface, text size
26
+ and bottom padding so the two layouts match; the box itself comes from a `LineBackgroundSpan` calling
27
+ `drawRoundRect`. That view pins its edge type to `EDGE_TYPE_NONE` on purpose: `SubtitlePainter` copies
28
+ the cue text for a second edge pass and draws both layouts whenever the edge type is `outline`,
29
+ `raised` or `depressed`, which would paint the box twice and make a semi-transparent colour composite
30
+ with itself and come out darker than asked for.
31
+
32
+ Leaving `backgroundRadius` unset takes the original code path unchanged, with no extra view and no
33
+ extra listener.
34
+
35
+ ### Changed
36
+
37
+ - **Android:** the subtitle view is now reconfigured whenever a player moves between views —
38
+ attaching a player, entering fullscreen, leaving it. `PlayerView` re-attaches its own subtitle view
39
+ on `setPlayer`, so nothing needed this before; the rounded background does, because it subscribes to
40
+ the `Player` rather than riding on `PlayerView`, and a view that no longer drives the player would
41
+ otherwise keep that subscription and go on drawing cues behind a surface nobody is looking at. For
42
+ anyone not using `backgroundRadius` this only means the caption style is re-applied at those
43
+ moments, which is what it already was.
44
+
45
+ ## 0.2.2
46
+
47
+ ### Changed
48
+
49
+ - The published tarball no longer carries the repo's own development configuration: `.prettierrc`,
50
+ `.gitattributes`, `eslint.config.js`, `tsconfig.all.json` and the two `*.tsbuildinfo` build caches
51
+ (260 files → 254). None of it is read from inside a consumer's `node_modules`, and
52
+ `eslint.config.js` would in fact throw if anything did load it, because it requires
53
+ `expo-module-scripts` — a devDependency, and therefore absent for consumers. Two conventions
54
+ combined to publish these: `.npmignore` excludes hidden *directories* but not hidden *files*, and
55
+ npm ignores `.gitignore` entirely once an `.npmignore` exists, so a build cache that git already
56
+ ignores still shipped. Nothing a consumer uses changed — the file list is otherwise byte-identical,
57
+ and installing the tarball into a clean project still resolves `build/`, `plugin/build/`,
58
+ `app.plugin.js`, `expo-module.config.json` and the Android/iOS sources.
59
+ - Dropped the `.npmignore` entry for `oxlint.config.mjs`, which has not existed since 0.2.0.
60
+
61
+ ## 0.2.1
62
+
63
+ ### Changed
64
+
65
+ - Repository tooling, with no effect on the published runtime: `npm run lint` is quiet again. Making
66
+ the lint step runnable in 0.2.0 exposed 377 `prettier/prettier` warnings against code that is in
67
+ fact correctly formatted. `eslint-config-universe` enables the `prettier/prettier` rule but supplies
68
+ no options of its own, and `expo-video` inherits them from the Expo monorepo's root `.prettierrc` —
69
+ a file that does not come along when the package is forked out to stand on its own. Prettier
70
+ therefore fell back to its own defaults, and every source file disagreed with them about quote style
71
+ and line width. Adding the upstream options as a `.prettierrc` takes the count to zero without
72
+ reformatting a single line.
73
+
74
+ ## 0.2.0
75
+
76
+ ### Added
77
+
78
+ - **Android:** `subtitleStyle.applyEmbeddedStyles` keeps the inline formatting a subtitle track
79
+ carries instead of discarding it. Subtitle formats bring styling of their own — SubRip's `<b>`,
80
+ `<i>`, `<u>` and `<font color>` tags, and the richer per-cue styling of embedded WebVTT/TTML — and
81
+ until now every bit of it was thrown away so that `subtitleStyle` was the only thing deciding how
82
+ captions looked. `SubtitleView.setApplyEmbeddedStyles(false)` was hardcoded, and Media3 implements
83
+ that by removing every span from every cue, so an `.srt` written with `<i>` rendered upright.
84
+ Defaults to `false`, so nothing changes until you opt in. The rest of `subtitleStyle` still applies
85
+ underneath and `fontSize` still overrides any size the track asks for; the trade-off is that a cue
86
+ specifying its own colours overrides `textColor` and `windowColor` for that cue. There is no iOS
87
+ equivalent: `AVPlayerItem.textStyleRules` always composes with what the WebVTT cue declares.
88
+
89
+ ### Fixed
90
+
91
+ - **Android:** a subtitle no longer drops down a line when the subtitle *below* it ends. Cues that
92
+ are on screen together are drawn as one block anchored at its bottom, so the moment the lower cue
93
+ expired the upper one fell into its place — moving the text at exactly the spot the viewer is
94
+ reading. Every cue now keeps the same line for its whole time on screen: the line a finished cue
95
+ leaves behind is held empty, and the next cue to start reclaims the lowest empty line rather than
96
+ stacking on top, which is what keeps the block from creeping upwards. An empty line can therefore
97
+ sit below — or between — the cues still on screen, and the stack resets once nothing is left on
98
+ screen. Cues with explicit `{\anN}` positioning are still passed through untouched.
99
+ - **Android:** merging simultaneous SubRip cues no longer flattens their text. The cues were joined
100
+ through `toString()`, which erases the spans Media3's SubRip parser produces from inline tags. This
101
+ changed nothing on screen at the time, because the renderer stripped those spans anyway, but it
102
+ would have silently defeated the new `applyEmbeddedStyles` option for any overlapping cue.
103
+
104
+ ### Changed
105
+
106
+ - Repository tooling, with no effect on the published runtime: `npm run lint` runs again. The repo
107
+ had no `eslint.config.js` — `expo-module configure` treats it as an optional template and never
108
+ creates one — while the `oxlint.config.mjs` it did have re-exported a module that
109
+ `expo-module-scripts@56` does not ship, so the lint step in `FORK.md` §5 had never actually
110
+ executed. A `.gitattributes` now pins the working copy to LF, which stops the Prettier rule from
111
+ reporting nearly every line of every file on a Windows checkout. The single lint error this
112
+ uncovered, a non-simple expression in a `useMemo` dependency list in `VideoPlayer.web.tsx`, is
113
+ fixed by hoisting it into a variable; the value and its identity semantics are unchanged.
114
+
10
115
  ## 0.1.3
11
116
 
12
117
  ### Documentation
package/FORK.md CHANGED
@@ -5,9 +5,10 @@ and (b) pull a newer `expo-video` release into it **without losing the subtitle
5
5
 
6
6
  - **Baseline:** `expo-video@56.1.4` (Expo SDK 56), as published on npm.
7
7
  - **Native module name:** unchanged (`ExpoVideo`) — this is a drop-in replacement, not a coexisting package.
8
- - **Design principle:** the fork is deliberately **surgical and additive**. 5 new files hold almost all
9
- of the logic (505 lines); the 14 touched upstream files receive ~130 added lines and only ~29 removed
10
- ones. Nothing upstream is rewritten, so a version bump is a small, mechanical re-apply.
8
+ - **Design principle:** the fork is deliberately **surgical and additive**. 7 new source files hold
9
+ almost all of the logic (~1,200 lines); the 19 touched upstream files receive ~450 added lines and
10
+ only ~44 removed ones. Nothing upstream is rewritten — the removals are call-site signatures and a
11
+ handful of rewritten function bodies — so a version bump is a small, mechanical re-apply.
11
12
 
12
13
  ---
13
14
 
@@ -23,17 +24,22 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
23
24
  | `ios/Records/SubtitleStyle.swift` | 118 | Record for caption styling; hex→ARGB parsing; builds `[AVTextStyleRule]`. |
24
25
  | `ios/VideoPlayerSubtitleSideload.swift` | 203 | Builds the `AVMutableComposition`, downloads remote subs, SRT→WebVTT converter. |
25
26
  | `android/…/records/SubtitleSource.kt` | 54 | Record → `MediaItem.SubtitleConfiguration` + MIME resolution. |
26
- | `android/…/records/SubtitleStyle.kt` | 129 | Record → `CaptionStyleCompat`; hex colour parsing; font-family resolution (`res/font` → `ReactFontManager`). |
27
- | `android/…/utils/StackingSubtitleParser.kt` | 214 | `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. |
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. |
29
+ | `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. |
28
30
  | `FORK.md`, `LICENSE` | — | This guide; MIT licence (upstream copyright preserved). |
31
+ | `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". |
32
+ | `.gitattributes` | 21 | Pins the working copy to LF. Git stores these files with LF, but a Windows checkout rewrites them to CRLF and the Prettier rule `npm run lint` enforces wants LF, which drowned the output in ~2800 "Delete `␍`" warnings. |
33
+ | `.prettierrc` | 7 | The Expo monorepo's Prettier options, which the sources were formatted with. `eslint-config-universe` turns the `prettier/prettier` rule *on* but supplies no options, and upstream inherits them from the monorepo root — a file that does not come along when the package is forked out on its own. Without this, Prettier falls back to its own defaults and reports 377 warnings against correctly formatted code. Only `printWidth` and `singleQuote` differ from Prettier 2's defaults today; the rest are written out because they are what upstream uses, and `trailingComma` guards against a bump to Prettier 3, whose default is `"all"`. |
29
34
 
30
35
  ### Modified — upstream files carrying fork edits (re-apply these after a sync)
31
36
 
32
37
  | File | Δ | What to re-apply |
33
38
  | --- | --- | --- |
34
39
  | `src/VideoPlayer.types.ts` | +59 | `subtitleTracks?: SubtitleSource[]` on `VideoSourceObject`; the exported `SubtitleSource` type. |
35
- | `src/VideoView.types.ts` | +94 | `subtitleStyle?: SubtitleStyle` on `VideoViewProps`; `SubtitleStyle` + `SubtitleEdgeType` types. |
40
+ | `src/VideoView.types.ts` | +129 | `subtitleStyle?: SubtitleStyle` on `VideoViewProps`; `SubtitleStyle` + `SubtitleEdgeType` types. |
36
41
  | `src/index.ts` | +2 | Export `SubtitleStyle`, `SubtitleEdgeType` (`SubtitleSource` rides on `export type *`). |
42
+ | `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. |
37
43
  | `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.** |
38
44
  | `ios/Records/VideoSource.swift` | +3 | `@Field var subtitleTracks: [SubtitleSource]?`. |
39
45
  | `ios/VideoPlayerItem.swift` | +17 / −2 | In the **async** init: hoist `isHls` to a local, and build the sideload composition for non-HLS/non-DRM sources before `super.init(asset:)`. |
@@ -41,14 +47,16 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
41
47
  | `ios/VideoView.swift` | +8 | `subtitleStyle` property; forward it when `player` is (re)assigned. |
42
48
  | `ios/VideoModule.swift` | +4 | `Prop("subtitleStyle")`. |
43
49
  | `android/…/records/VideoSource.kt` | +9 / −2 | `subtitleTracks` field; `setSubtitleConfigurations(…)` in `toMediaItem()`; subtitle key in `toMediaId()`. |
44
- | `android/…/utils/SubtitleUtils.kt` | +27 / −16 | `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. |
45
- | `android/…/VideoView.kt` | +12 / −5 | `subtitleStyle` property; pass it at all 4 `configureSubtitleView` call sites + the captioning listener. |
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`). |
51
+ | `android/…/VideoView.kt` | +16 / −5 | `subtitleStyle` property; pass it at all 4 `configureSubtitleView` call sites + the captioning listener. |
46
52
  | `android/…/VideoModule.kt` | +4 | `Prop("subtitleStyle")`. |
47
53
  | `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. |
54
+ | `android/…/player/VideoPlayer.kt` | +13 / −2 | In `changeVideoView`, after `switchTargetView`: release the subtitle view the player is leaving and reconfigure the one it moves to. **Load-bearing for `backgroundRadius`** — that feature subscribes to the `Player` directly rather than riding on `PlayerView.setPlayer`, so a view that no longer drives the player keeps its subscription and goes on drawing stale cues. This is the choke point for every player move except entering fullscreen, which bypasses it (see `FullscreenPlayerActivity`). |
48
55
  | `android/…/player/VideoPlayerSubtitles.kt` | +14 / −8 | Apply the cleared track-selection parameters when the requested subtitle id matches nothing, instead of only inside the `format?.let` block. |
49
- | `android/…/FullscreenPlayerActivity.kt` | +3 / −3 | Pass `videoView.subtitleStyle` at its 2 call sites + the captioning listener. |
56
+ | `android/…/FullscreenPlayerActivity.kt` | +15 / −3 | Pass `videoView.subtitleStyle` at its 2 call sites + the captioning listener. Also release the originating `VideoView`'s subtitle view right after its own `switchTargetView` — this path bypasses `changeVideoView`, so without it the player carries two cue listeners for the whole time it is fullscreen — and release this activity's own in `onDestroy`. |
50
57
  | `package.json` | — | Fork identity + build scripts — see §3. |
51
58
  | `expo-module.config.json` | — | **Android `publication` block removed** — see §3. |
59
+ | `.npmignore` | +21 / −1 | A "Fork additions" block excluding the repo's own development config from the tarball: `.gitattributes`, `.prettierrc`, `eslint.config.js`, `tsconfig.all.json` and `*.tsbuildinfo`. Needed because the generated file only excludes hidden *directories* (`/.*/`), and because npm ignores `.gitignore` outright once an `.npmignore` exists — so the tsc build caches shipped despite git ignoring them. The `−1` drops the entry for `oxlint.config.mjs`, deleted in 0.2.0. |
52
60
  | `README.md`, `CHANGELOG.md` | — | Fork docs; do not take upstream's. |
53
61
 
54
62
  ### Removed — upstream artifacts deliberately dropped
@@ -58,6 +66,7 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
58
66
  | `local-maven-repo/` (Android AAR) | Upstream ships a **precompiled** `expo.modules.video` AAR. If present, Gradle resolves it and **silently ignores the modified Kotlin**. |
59
67
  | `prebuilds/` (iOS XCFramework) | Same trap on iOS: CocoaPods links the prebuilt `ExpoVideo.xcframework` instead of compiling the modified Swift. |
60
68
  | `spm.config.json` | Swift Package Manager prebuild config; meaningless once we build from source. |
69
+ | `oxlint.config.mjs` | Re-exported `expo-module-scripts/oxlint.config.base`, which does not exist in `expo-module-scripts@56` — that package lints with ESLint and ships no oxlint base. A dead file that only misled readers into thinking oxlint was the configured linter. |
61
70
 
62
71
  > **This is the single most important thing to preserve.** If a sync re-introduces the `publication`
63
72
  > block, `local-maven-repo/` or `prebuilds/`, the app keeps building — it just runs **upstream's**
@@ -105,7 +114,11 @@ Then re-apply the fork:
105
114
  with real deletions are `SubtitleUtils.kt` (function rewritten), `VideoView.kt`/`FullscreenPlayerActivity.kt`
106
115
  (call-site signatures), `VideoPlayerItem.swift` (`isHls` hoist) and the ts-declarations shim.
107
116
  7. **Remove the Android `publication` block** from `expo-module.config.json`.
108
- 8. **Restore the fork's `package.json`** (§3) and keep `README.md`, `CHANGELOG.md`, `LICENSE`, `FORK.md`.
117
+ 8. **Restore the fork's `package.json`** (§3) and keep `README.md`, `CHANGELOG.md`, `LICENSE`,
118
+ `FORK.md`, `eslint.config.js`, `.gitattributes` and `.prettierrc`. Step 3 only replaces
119
+ `src ios android plugin`, so the root-level files survive on their own — but confirm it, because a
120
+ missing `eslint.config.js` breaks `npm run lint` in a way that reads like a tooling bug rather than
121
+ a lost file, and a missing `.prettierrc` buries the output in warnings against correct code.
109
122
  9. Update the baseline version in this file, the README, and add a `CHANGELOG.md` entry.
110
123
  10. Verify with §5, then commit.
111
124
 
@@ -154,29 +167,107 @@ entry, in §1 of this file and in the README's intro line.
154
167
 
155
168
  ## 5. Verification
156
169
 
157
- Run all four. The native ones are what actually prove the fork survived the sync.
170
+ > **This package is standalone.** Nothing in this section may reach into an unrelated project's
171
+ > checkout. Verifying by overlaying the package onto some other app's `node_modules` silently mutates
172
+ > a repository that has nothing to do with this one, and couples a public npm package to a private
173
+ > codebase. If a step needs a host app, create a throwaway one for that purpose.
174
+
175
+ ### Self-contained — run these on every change
158
176
 
159
177
  ```bash
160
178
  npm run typecheck # must be clean
161
- npm run lint
179
+ npm run lint # expect zero output: no errors and no warnings
162
180
  npm pack --dry-run # expect build/, plugin/build/, LICENSE, native sources;
163
- # NO node_modules, prebuilds, local-maven-repo
181
+ # NO node_modules, prebuilds, local-maven-repo;
182
+ # NO dev config (.prettierrc, eslint.config.js, *.tsbuildinfo) — see §1
183
+ ```
184
+
185
+ The file count is a blunt but effective regression check: **254 files** as of 0.2.2. If a change moves
186
+ it, diff the list against the previous release and confirm every difference is intended —
187
+ `npm pack --dry-run --json` gives a machine-readable list. Both directions matter: an unexpected
188
+ *addition* means dev clutter leaked in, an unexpected *removal* means a consumer is missing something.
189
+
190
+ `npm run lint` depends on two root files that upstream does not carry, both described in §1:
191
+ `eslint.config.js` (without it ESLint refuses to run at all) and `.prettierrc` (without it Prettier
192
+ falls back to its own defaults and reports hundreds of warnings against correctly formatted code).
193
+ Neither is created by `expo-module configure`, so a sync that loses one breaks this step in a way
194
+ that reads like a tooling bug. A `prettier/prettier` warning after a sync is far more likely to mean
195
+ the config went missing than that the code is genuinely misformatted — check that first.
196
+
197
+ ### Android — compiling the parser without a host app
198
+
199
+ `utils/StackingSubtitleParser.kt` imports only `android.text.*` and `androidx.media3.*`, so it
200
+ compiles on its own. This is the file that carries almost all of the Android subtitle logic, and it
201
+ is the one most likely to break when Media3 changes `SubtitleParser`, `CuesWithTiming` or `Cue`.
202
+
203
+ In a scratch directory, with `<media3>` set to `androidxMedia3Version` from `android/build.gradle`
204
+ and `<sdk>` to an installed Android platform:
205
+
206
+ ```kotlin
207
+ // settings.gradle.kts -> rootProject.name = "kotlincheck"
208
+ // build.gradle.kts:
209
+ import java.util.zip.ZipFile
210
+
211
+ plugins { kotlin("jvm") version "2.1.21" }
212
+ repositories { google(); mavenCentral() }
213
+
214
+ val media3: Configuration by configurations.creating
215
+ dependencies {
216
+ media3("androidx.media3:media3-common:<media3>")
217
+ media3("androidx.media3:media3-extractor:<media3>")
218
+ media3("androidx.media3:media3-ui:<media3>")
219
+ }
220
+
221
+ // A plain JVM compile cannot read .aar, so pull classes.jar out of each one.
222
+ val unpacked = layout.buildDirectory.dir("unpacked")
223
+ val unpackAars by tasks.registering {
224
+ outputs.dir(unpacked)
225
+ doLast {
226
+ val out = unpacked.get().asFile.also { it.deleteRecursively(); it.mkdirs() }
227
+ media3.forEach { f ->
228
+ if (f.name.endsWith(".aar")) {
229
+ ZipFile(f).use { zip ->
230
+ zip.getEntry("classes.jar")?.let { e ->
231
+ zip.getInputStream(e).use { it.copyTo(File(out, f.name.removeSuffix(".aar") + ".jar").outputStream()) }
232
+ }
233
+ }
234
+ } else if (f.name.endsWith(".jar")) f.copyTo(File(out, f.name), overwrite = true)
235
+ }
236
+ }
237
+ }
238
+
239
+ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
240
+ dependsOn(unpackAars)
241
+ libraries.from(fileTree(unpacked) { include("*.jar") }, files("<sdk>/platforms/android-36/android.jar"))
242
+ }
164
243
  ```
165
244
 
166
- **Native compile** — because both platforms default to precompiled artifacts, you must build from
167
- source inside a host app. Overlay this package into the app's `node_modules/expo-video`, then:
245
+ Copy the file to `src/main/kotlin/expo/modules/video/utils/` and run `gradle compileKotlin` —
246
+ expect `BUILD SUCCESSFUL`. Because the class implements Media3's `SubtitleParser`, a successful
247
+ build also proves the three overrides still match that Media3 version's interface.
248
+
249
+ The rest of the Android sources import `expo.modules.kotlin` and `com.facebook.react`, neither of
250
+ which is on a public Maven repository, so they cannot be checked this way — a full build is the only
251
+ option for them.
252
+
253
+ ### Full native build — needs a throwaway host app
254
+
255
+ Both platforms default to precompiled artifacts, so a complete check means building from source in a
256
+ host app. Create one for this purpose (`npx create-expo-app`), install this package into it, and
257
+ confirm §1's "Removed" artifacts are absent from the installed copy:
168
258
 
169
259
  ```bash
170
260
  # Android — expect BUILD SUCCESSFUL
171
- cd <app>/android && ./gradlew :expo-video:compileDebugKotlin
261
+ cd <scratch-app>/android && ./gradlew :expo-video-subtitle:compileDebugKotlin
172
262
 
173
263
  # iOS — expect BUILD SUCCEEDED
174
- cd <app>/ios && pod install
264
+ cd <scratch-app>/ios && pod install
175
265
  xcodebuild -project Pods/Pods.xcodeproj -target ExpoVideo -sdk iphoneos \
176
266
  -configuration Debug build CODE_SIGNING_ALLOWED=NO
177
267
  ```
178
268
 
179
- Back up `node_modules/expo-video` first and restore it (plus `pod install`) afterwards.
269
+ `npm pack` the fork and install the tarball rather than editing the app's `node_modules` in place,
270
+ so the thing you compile is exactly what a consumer would receive.
180
271
 
181
272
  **Smoke test in the app:** an MP4 source with `subtitleTracks` must list the tracks in
182
273
  `player.availableSubtitleTracks`, render when selected via `player.subtitleTrack`, and visibly change
package/README.md CHANGED
@@ -89,6 +89,25 @@ Nah, ayo pergi. <- started earlier
89
89
 
90
90
  This matches how WebVTT stacks cues, and behaves the same on Android and iOS. Cues that carry
91
91
  explicit positioning (SubRip's `{\anN}` tags) are left where they were placed and are not merged.
92
+ Any inline formatting the merged cues carry is preserved, so it survives an overlap unchanged — see
93
+ [Inline formatting from the track](#inline-formatting-from-the-track) for when that formatting renders.
94
+
95
+ **A cue never changes line while it is on screen** (Android). Because the block is anchored at its
96
+ bottom, a cue would otherwise drop down the moment the cue *below* it ended — moving the text you are
97
+ reading at that moment. The line a finished cue leaves behind is held empty instead, and the next cue
98
+ to start reclaims the lowest empty line rather than stacking on top:
99
+
100
+ ```
101
+ Apa mereka berdua berteman, ya? both on screen
102
+ Nah, ayo pergi.
103
+
104
+ Apa mereka berdua berteman, ya? the lower cue ended — the upper one stays put
105
+ (held empty, reclaimed by whichever cue starts next)
106
+ ```
107
+
108
+ The empty line can also sit *between* two cues when the middle one finishes first. Once nothing is
109
+ left on screen, the stack resets and the next cue starts from the bottom again. This is Android-only;
110
+ on iOS the system renderer decides the layout.
92
111
 
93
112
  ## Styling subtitles
94
113
 
@@ -100,6 +119,7 @@ Pass a `subtitleStyle` prop to `VideoView`. It applies to whichever subtitle tra
100
119
  subtitleStyle={{
101
120
  textColor: '#FFFFFF',
102
121
  backgroundColor: '#000000A0', // box drawn behind the text
122
+ backgroundRadius: 8, // Android only — corner radius of that box, in dp
103
123
  windowColor: 'transparent', // background of the whole cue region
104
124
  fontSize: 20, // Android: absolute sp · iOS: percent relative to default (100 = default)
105
125
  fontFamily: 'Helvetica',
@@ -107,6 +127,7 @@ Pass a `subtitleStyle` prop to `VideoView`. It applies to whichever subtitle tra
107
127
  edgeType: 'outline', // 'none' | 'outline' | 'dropShadow' | 'raised' | 'depressed'
108
128
  edgeColor: '#000000',
109
129
  bottomOffset: 0.1, // Android only — fraction of the view height (0–1)
130
+ applyEmbeddedStyles: false, // Android only — see "Inline formatting from the track"
110
131
  }}
111
132
  />
112
133
  ```
@@ -115,7 +136,47 @@ Pass a `subtitleStyle` prop to `VideoView`. It applies to whichever subtitle tra
115
136
  | --- | --- | --- |
116
137
  | Implementation | `CaptionStyleCompat` on the `PlayerView` subtitle view | `AVPlayerItem.textStyleRules` |
117
138
 
118
- > **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` and `bottomOffset` are Android-only.
139
+ > **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.
140
+
141
+ ### Rounded background
142
+
143
+ `backgroundRadius` rounds the corners of the `backgroundColor` box. It is a dp value, and it does
144
+ nothing unless `backgroundColor` is set to something that isn't fully transparent:
145
+
146
+ ```tsx
147
+ <VideoView player={player} subtitleStyle={{ backgroundColor: '#000000A0', backgroundRadius: 8 }} />
148
+ ```
149
+
150
+ One box is drawn per rendered line, so a caption that wraps onto two lines gets two rounded boxes
151
+ stacked directly on top of each other — the way VLC and most desktop players draw them. Boxes on
152
+ adjacent lines share an edge; the rounding is on the outside of the block.
153
+
154
+ `windowColor` is **not** affected and stays rectangular. The two are different things: the window is
155
+ the region the caption occupies, the background is the box that hugs the text.
156
+
157
+ > Media3 has no rounded-caption support of its own — it paints both the background and the window
158
+ > with `canvas.drawRect`. This option is implemented by rendering the background in a second
159
+ > subtitle view placed behind the text, so it costs one extra view and one cue listener per player,
160
+ > and only while it is switched on. Leaving `backgroundRadius` unset takes the original code path
161
+ > exactly as before.
162
+
163
+ ### Inline formatting from the track
164
+
165
+ Subtitle files carry formatting of their own — `<b>`, `<i>`, `<u>` and `<font color>` tags in `SRT`, and
166
+ richer per-cue styling in embedded `WebVTT`/`TTML` tracks. **Android strips all of it by default**, so
167
+ `subtitleStyle` is the only thing deciding how captions look. Opt in to keep it:
168
+
169
+ ```tsx
170
+ <VideoView player={player} subtitleStyle={{ textColor: '#FFFFFF', applyEmbeddedStyles: true }} />
171
+ ```
172
+
173
+ The rest of `subtitleStyle` still applies underneath, and `fontSize` keeps overriding any size the track
174
+ asks for. Only the properties a cue explicitly sets change — an unstyled cue looks identical either way.
175
+ The trade-off is that a track specifying its own colors overrides `textColor` and `windowColor` for those
176
+ cues, so leave this off when the captions must match your UI exactly.
177
+
178
+ On iOS this is not configurable: `AVPlayerItem.textStyleRules` composes with whatever the WebVTT cue
179
+ declares, so a track's inline formatting always renders.
119
180
 
120
181
  ### Fonts
121
182
 
@@ -83,6 +83,12 @@ class FullscreenPlayerActivity : Activity(), VideoManagerListener {
83
83
  videoPlayer = videoView.videoPlayer
84
84
  videoPlayer?.player?.let {
85
85
  PlayerView.switchTargetView(it, videoView.playerView, playerView)
86
+ // This bypasses `VideoPlayer.changeVideoView`, so the handover it performs has to be done here:
87
+ // the view we took the player from keeps its own `Player` subscription otherwise, leaving two
88
+ // listeners on one player for the whole time we are in fullscreen. `onPostCreate` configures
89
+ // this activity's own player view, and `exitFullscreen` restores the original through
90
+ // `changeVideoView`.
91
+ SubtitleUtils.releaseSubtitleView(videoView.playerView)
86
92
  videoPlayer?.hasBeenDisconnectedFromVideoView() // The video player is disconnected. We are only using the ExoPlayer it contained
87
93
  }
88
94
 
@@ -180,6 +186,12 @@ class FullscreenPlayerActivity : Activity(), VideoManagerListener {
180
186
  captioningChangeListener = null
181
187
  }
182
188
 
189
+ // Release whatever `configureSubtitleView` attached to this activity's own player view; the
190
+ // `VideoView` it came from keeps its own, which `exitFullscreen` reconfigures.
191
+ if (::playerView.isInitialized) {
192
+ SubtitleUtils.releaseSubtitleView(playerView)
193
+ }
194
+
183
195
  if (::videoView.isInitialized) {
184
196
  videoView.exitFullscreen()
185
197
  }
@@ -389,6 +389,10 @@ open class VideoView(context: Context, appContext: AppContext, useTextureView: B
389
389
  captioningChangeListener = null
390
390
  }
391
391
 
392
+ // Release whatever `configureSubtitleView` attached to the player view. `onAttachedToWindow`
393
+ // reconfigures from scratch, so there is nothing to preserve across a detach.
394
+ SubtitleUtils.releaseSubtitleView(playerView)
395
+
392
396
  // Clean up window focus listener
393
397
  decorView.onFocusChangeListener = null
394
398
 
@@ -46,6 +46,7 @@ import expo.modules.video.records.SeekTolerance
46
46
  import expo.modules.video.records.TimeUpdate
47
47
  import expo.modules.video.records.VideoSource
48
48
  import expo.modules.video.utils.MutableWeakReference
49
+ import expo.modules.video.utils.SubtitleUtils
49
50
  import expo.modules.video.records.VideoTrack
50
51
  import expo.modules.video.utils.buildBasicMediaSession
51
52
  import kotlinx.coroutines.DelicateCoroutinesApi
@@ -406,8 +407,18 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
406
407
  }
407
408
 
408
409
  fun changeVideoView(videoView: VideoView?) {
409
- PlayerView.switchTargetView(player, currentVideoView?.playerView, videoView?.playerView)
410
+ val previousPlayerView = currentVideoView?.playerView
411
+ PlayerView.switchTargetView(player, previousPlayerView, videoView?.playerView)
410
412
  currentVideoView = videoView
413
+
414
+ // Subtitle rendering has to follow the player, and only after the swap so the rebind below reads
415
+ // the new `playerView.player`. `PlayerView` re-attaches its own subtitle view on `setPlayer`, but
416
+ // the rounded background subscribes to the `Player` directly, so a view that no longer drives the
417
+ // player would keep that subscription and go on drawing stale cues.
418
+ if (previousPlayerView != null && previousPlayerView !== videoView?.playerView) {
419
+ SubtitleUtils.releaseSubtitleView(previousPlayerView)
420
+ }
421
+ videoView?.let { SubtitleUtils.configureSubtitleView(it.playerView, it.context, it.subtitleStyle) }
411
422
  }
412
423
 
413
424
  fun prepare() {
@@ -26,7 +26,20 @@ class SubtitleStyle(
26
26
  @Field val bold: Boolean = false,
27
27
  @Field val edgeType: String? = null,
28
28
  @Field val edgeColor: String? = null,
29
- @Field val bottomOffset: Float? = null
29
+ @Field val bottomOffset: Float? = null,
30
+ /**
31
+ * Corner radius, in dp, of the [backgroundColor] box. Consumed by
32
+ * [expo.modules.video.utils.SubtitleUtils.configureSubtitleView] rather than [toCaptionStyle]:
33
+ * [CaptionStyleCompat] has no radius, and Media3 draws the box with `canvas.drawRect`, so a rounded
34
+ * one has to be rendered separately — see [expo.modules.video.utils.RoundedSubtitleBackground].
35
+ */
36
+ @Field val backgroundRadius: Float? = null,
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.
41
+ */
42
+ @Field val applyEmbeddedStyles: Boolean = false
30
43
  ) : Record, Serializable {
31
44
  /**
32
45
  * Builds a [CaptionStyleCompat], falling back to [base] for any unspecified property so that