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 +105 -0
- package/FORK.md +109 -18
- package/README.md +62 -1
- package/android/src/main/java/expo/modules/video/FullscreenPlayerActivity.kt +12 -0
- package/android/src/main/java/expo/modules/video/VideoView.kt +4 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayer.kt +12 -1
- package/android/src/main/java/expo/modules/video/records/SubtitleStyle.kt +14 -1
- package/android/src/main/java/expo/modules/video/utils/RoundedSubtitleBackground.kt +352 -0
- package/android/src/main/java/expo/modules/video/utils/StackingSubtitleParser.kt +113 -24
- package/android/src/main/java/expo/modules/video/utils/SubtitleUtils.kt +64 -6
- package/build/VideoPlayer.web.d.ts.map +1 -1
- package/build/VideoPlayer.web.js +5 -1
- package/build/VideoPlayer.web.js.map +1 -1
- package/build/VideoView.types.d.ts +33 -0
- package/build/VideoView.types.d.ts.map +1 -1
- package/build/VideoView.types.js.map +1 -1
- package/package.json +1 -1
- package/src/VideoPlayer.web.tsx +5 -1
- package/src/VideoView.types.ts +35 -0
- package/plugin/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.all.json +0 -11
- package/tsconfig.tsbuildinfo +0 -1
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**.
|
|
9
|
-
of the logic (
|
|
10
|
-
ones. Nothing upstream is rewritten
|
|
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` |
|
|
27
|
-
| `android/…/utils/StackingSubtitleParser.kt` |
|
|
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` | +
|
|
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` | +
|
|
45
|
-
| `android/…/VideoView.kt` | +
|
|
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` | +
|
|
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`,
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|