expo-video-subtitle 0.7.0 → 0.7.2

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,61 @@ 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.7.2
11
+
12
+ ### Fixed
13
+
14
+ - **Signs did not render at all (Android).** 0.7.0 handed each sign to the overlay on a custom span
15
+ attached to a cue. Nothing survives that trip: `SubtitleExtractor` writes every `CuesWithTiming`
16
+ into the sample queue through `CueEncoder`, which calls `Cue.toSerializableBundle()` and then
17
+ `Parcel.marshall()`, and a span is only preserved if it is one of the three Media3 declares in
18
+ `CustomSpanBundler` or a framework `ParcelableSpan`. Anything else is dropped silently. The cue
19
+ arrived on the other side as the zero-width placeholder with nothing attached, so the overlay found
20
+ no signs — no crash, no log, just missing typesetting while the dialogue rendered normally.
21
+
22
+ Signs now go to `AssSignStore`, keyed by the track's `Format.id`, and the overlay reads the
23
+ player's position on each frame to decide what is on screen. That also removes two problems the
24
+ cue-stream route carried with it: 12,616 sign events would have put ~26,000 boundaries through the
25
+ timeline pass in `StackingSubtitleParser` — an O(n²) sweep over every entry at every boundary — and
26
+ written ~26,000 samples into the queue. The dialogue-only file left for Media3 holds 377 events.
27
+
28
+ - **Scale was applied between the two rotations instead of before both.** Android's `Matrix.post*`
29
+ methods pre-multiply, so the calls apply in the order they are written, and the camera step ran
30
+ first. ASS scales a glyph and then turns it; with an anisotropic scale — this episode carries
31
+ `\fscx125.86` against `\fscy120.19` — the two do not commute, so the shape itself was wrong.
32
+
33
+ - **The overlay re-laid-out every sign whenever any one of them changed.** Frame-by-frame typesetting
34
+ changes the on-screen set every 40 ms, so a scene holding ten placards rebuilt all ten `StaticLayout`s
35
+ on every tick. Layouts are now kept by sign identity and only new signs are laid out.
36
+
37
+ - **A timestamp with no hour fell through to flat rendering.** `MM:SS.cc` is legal — Media3's own
38
+ `SSA_TIMECODE_PATTERN` makes the hour optional — but this parser required it and skipped the event,
39
+ which then stayed in the copy handed to Media3. Media3 reads `\pos` perfectly well and drew the
40
+ sign flat, with nothing in the logs to say it had happened.
41
+
42
+ ## 0.7.1
43
+
44
+ ### Fixed
45
+
46
+ - **Playing an `.ass` track crashed the player (Android).** `Regex(""""\\{([^}]*)}""")` in the sign
47
+ parser left the closing brace unescaped. `java.util.regex` accepts that; Android compiles regular
48
+ expressions with **ICU**, which does not, so the pattern threw `PatternSyntaxException` out of a
49
+ static initialiser on the extractor's loading thread and took playback down with it —
50
+ `ExceptionInInitializerError` on the first ASS subtitle the app opened.
51
+
52
+ The whole class of bug is invisible to this module's JVM tests, because the offending pattern
53
+ compiles there. `RegexPortabilityTest` now reads the sources instead and fails on any regex
54
+ literal with a brace that is neither escaped nor inside a character class.
55
+
56
+ - **A subtitle this package cannot read no longer stops the video.** `StackingSsaParser.split` runs
57
+ on the extractor's loading thread, where a thrown exception is not a failed subtitle — it is a
58
+ dead player. It is now wrapped end to end, and a track that fails falls back to Media3's own ASS
59
+ handling, which draws the captions flat. Subtitle files are untrusted scraper output; plain
60
+ captions are the honest failure, a black screen is not.
61
+
62
+ - **A multi-line sign follows its own alignment.** Every block was laid out centred, so the second
63
+ line of a left- or right-anchored placard pulled to the middle.
64
+
10
65
  ## 0.7.0
11
66
 
12
67
  ### Added
package/FORK.md CHANGED
@@ -30,8 +30,8 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
30
30
  | `android/…/utils/StackingSubtitleParser.kt` | 342 | `SubtitleParser` that segments the cue timeline and merges simultaneous **unpositioned** cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip and ASS/SSA to it and wraps every parser in `FilteringSubtitleParser`. ASS gets there through `SsaCueNormalizer` as the `prepare` step — without it `SsaParser` sets both `line` and `position` on every cue and they would all take the passthrough branch. 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. |
31
31
  | `android/…/utils/SsaCueNormalizer.kt` | 93 | Clears the `line`/`position` Media3's `SsaParser` fills in on cues the ASS author never placed, so `bottomOffset` and cue stacking work on `.ass` the way they already do on `.srt`. The test is exact rather than heuristic: those values are a pure function of the cue's anchors, so matching both defaults for the bottom-centre anchors *is* "no positioning tag". Also drops events on a style the author made invisible (`Fontsize 0`, or a primary colour whose inverted alpha byte is `FF`) — that is where typesetters park working notes, and nothing downstream can carry "invisible". |
32
32
  | `android/…/utils/AssSignParser.kt` | 505 | Splits an ASS script into the typeset events this package draws and a copy of the script with those lines removed for Media3's parser. One pass, no matching step, so an event is in exactly one of the two outputs and cannot be drawn twice. Pure Kotlin with no Android imports, which is what makes it unit-testable on the JVM. Reads `\pos`, `\move`, `\org`, `\an`, `\frz`, `\frx`, `\fry`, `\fscx/y`, `\fs`, `\c`, `\alpha`, `\b`, `\i`, `\u` and the style columns behind them. The tag scanner takes the longest name first — `\c` must not swallow `\clip`, `\b` must not swallow `\bord`, `\blur` or `\be` — and reads a leading digit as part of the name for `\1c` and `\4a`. |
33
- | `android/…/utils/AssSignOverlay.kt` | 235 | Draws those signs on a canvas in front of the captions, with `Matrix` for rotation and scale and `Camera` for the two 3-D axes. **Media3 cannot do this**: `SubtitlePainter` has no rotate, skew, Matrix or Camera call, and `Cue` has no rotation field. Sits in the same parent as `SubtitleView` — the content frame, sized to the video, which is why `\pos` survives letterboxing. Lays text out on change rather than per frame, because frame-by-frame typesetting changes the on-screen set every 40 ms. |
34
- | `android/…/utils/AssSignSpan.kt` | 24 | Carries one parsed sign through Media3's cue stream to the overlay, on a zero-width placeholder so `SubtitleView` draws nothing. Riding the cue stream is what buys the timing for free. |
33
+ | `android/…/utils/AssSignOverlay.kt` | 330 | Draws those signs on a canvas in front of the captions, with `Matrix` for rotation and scale and `Camera` for the two 3-D axes. Drives itself from the player's clock on a `Choreographer` callback, because signs do not ride the cue stream and nothing else would tell it when one starts; 40 ms events need frame granularity anyway. **Media3 cannot do this**: `SubtitlePainter` has no rotate, skew, Matrix or Camera call, and `Cue` has no rotation field. Sits in the same parent as `SubtitleView` — the content frame, sized to the video, which is why `\pos` survives letterboxing. Lays text out on change rather than per frame, because frame-by-frame typesetting changes the on-screen set every 40 ms. |
34
+ | `android/…/utils/AssSignStore.kt` | 61 | Hands parsed signs from the parser to the overlay, keyed by the track's `Format.id`. **Not** the cue stream: `CueEncoder` marshals every cue through `Parcel`, and a custom span that is neither a framework `ParcelableSpan` nor one of Media3's three `CustomSpanBundler` types is dropped without a word — which is how 0.7.0 shipped with signs that parsed correctly and never appeared. Staying out of the cue stream also keeps 12,616 sign events from putting ~26,000 boundaries through the O(n²) timeline pass. |
35
35
  | `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. |
36
36
  | `FORK.md`, `LICENSE` | — | This guide; MIT licence (upstream copyright preserved). |
37
37
  | `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". |
@@ -11,12 +11,13 @@ import android.graphics.Typeface
11
11
  import android.text.Layout
12
12
  import android.text.StaticLayout
13
13
  import android.text.TextPaint
14
+ import android.view.Choreographer
14
15
  import android.view.View
15
16
  import android.view.ViewGroup
16
17
  import androidx.annotation.OptIn
18
+ import androidx.media3.common.C
17
19
  import androidx.media3.common.Player
18
- import androidx.media3.common.text.Cue
19
- import androidx.media3.common.text.CueGroup
20
+ import androidx.media3.common.Tracks
20
21
  import androidx.media3.common.util.UnstableApi
21
22
  import androidx.media3.ui.PlayerView
22
23
  import androidx.media3.ui.SubtitleView
@@ -25,9 +26,10 @@ import androidx.media3.ui.SubtitleView
25
26
  * Draws ASS typesetting — signs, placards, on-screen labels — with the transforms the script asks
26
27
  * for.
27
28
  *
28
- * `SubtitleView` cannot: `SubtitlePainter` has no rotation, and `Cue` has no field to carry one.
29
- * So [AssSignParser] takes those events out of Media3's cue stream, wraps each in an [AssSignSpan],
30
- * and this view draws them itself.
29
+ * `SubtitleView` cannot: `SubtitlePainter` has no call to `rotate`, `skew`, `Matrix` or `Camera`,
30
+ * and `Cue` has no rotation field, only `shearDegrees`, which the painter ignores. So
31
+ * [AssSignParser] takes those events out of the file Media3 parses and leaves them in
32
+ * [AssSignStore], and this view draws them.
31
33
  *
32
34
  * ### Where it sits
33
35
  *
@@ -36,93 +38,166 @@ import androidx.media3.ui.SubtitleView
36
38
  * survive letterboxing. `\pos` is in `PlayResX`/`PlayResY` space and maps onto this view's bounds
37
39
  * directly. [RoundedSubtitleBackground] takes the same route for the same reason.
38
40
  *
39
- * ### Why a canvas and not child views
41
+ * ### Why it drives itself from the clock
40
42
  *
41
- * A `TextView` per sign would workAndroid view transforms map onto ASS almost one for one — but
42
- * a heavily typeset episode puts dozens of signs on screen across a scene and each would cost a
43
- * measure and layout pass. Drawing straight to the canvas costs one `StaticLayout` per sign per
44
- * frame it changes, and it is the only way to stroke the outline the way ASS means it: the border
45
- * drawn *behind* the fill, not as a shadow.
43
+ * Signs do not ride the cue stream see [AssSignStore] for why they cannot so nothing tells this
44
+ * view when one begins. It reads the player's position on each frame instead, which is also the only
45
+ * granularity that works: frame-by-frame typesetting writes one event per frame, and a sign on
46
+ * screen for 40 ms has to arrive and leave on the frame it was authored for.
46
47
  */
47
48
  @SuppressLint("ViewConstructor")
48
49
  @OptIn(UnstableApi::class)
49
50
  internal class AssSignOverlay(context: Context) : View(context), Player.Listener {
50
51
 
51
52
  private val camera = Camera()
52
- private val matrix = Matrix()
53
53
 
54
- private var signs: List<AssSign> = emptyList()
55
- private var prepared: List<PreparedSign> = emptyList()
56
54
  private var player: Player? = null
55
+ private var script: AssScript? = null
56
+
57
+ private var active: List<AssSign> = emptyList()
58
+ private var prepared: List<PreparedSign> = emptyList()
59
+
60
+ /**
61
+ * Keyed by identity, because [AssSignParser] produces one instance per event and hands back the
62
+ * same one for every frame it is on screen. Without it, a scene holding ten placards while an
63
+ * eleventh animates would re-lay-out all eleven on every 40 ms tick.
64
+ */
65
+ private var layouts = HashMap<AssSign, PreparedSign>()
66
+
67
+ private var frameScheduled = false
68
+ private val onFrame = Choreographer.FrameCallback {
69
+ frameScheduled = false
70
+ tick()
71
+ schedule()
72
+ }
73
+
74
+ // -----------------------------------------------------------------------------------------
75
+ // Binding
76
+ // -----------------------------------------------------------------------------------------
77
+
78
+ fun attachTo(player: Player?) {
79
+ if (this.player !== player) {
80
+ this.player?.removeListener(this)
81
+ this.player = player
82
+ player?.addListener(this)
83
+ }
84
+ // Re-resolved unconditionally: the same player switches between text tracks, and this doubles
85
+ // as the first resolve, before `onTracksChanged` has had anything to say.
86
+ resolveScript()
87
+ schedule()
88
+ }
89
+
90
+ fun detach() {
91
+ player?.removeListener(this)
92
+ player = null
93
+ script = null
94
+ layouts = HashMap()
95
+ setActive(emptyList())
96
+ }
97
+
98
+ override fun onTracksChanged(tracks: Tracks) {
99
+ resolveScript()
100
+ schedule()
101
+ }
102
+
103
+ private fun resolveScript() {
104
+ val next = AssSignStore.get(selectedTextTrackId())
105
+ if (next === script) return
106
+ script = next
107
+ layouts = HashMap()
108
+ setActive(emptyList())
109
+ }
110
+
111
+ /**
112
+ * `Format.id` of the selected text track, which `SubtitleSource.toSubtitleConfiguration` sets to
113
+ * `expo-subtitle:<language>:<uri>` — the same value the parser filed its signs under.
114
+ */
115
+ private fun selectedTextTrackId(): String? {
116
+ val groups = player?.currentTracks?.groups ?: return null
117
+ for (group in groups) {
118
+ if (group.type != C.TRACK_TYPE_TEXT) continue
119
+ for (index in 0 until group.length) {
120
+ if (group.isTrackSelected(index)) return group.getTrackFormat(index).id
121
+ }
122
+ }
123
+ return null
124
+ }
125
+
126
+ // -----------------------------------------------------------------------------------------
127
+ // The clock
128
+ // -----------------------------------------------------------------------------------------
129
+
130
+ override fun onAttachedToWindow() {
131
+ super.onAttachedToWindow()
132
+ schedule()
133
+ }
134
+
135
+ override fun onDetachedFromWindow() {
136
+ super.onDetachedFromWindow()
137
+ Choreographer.getInstance().removeFrameCallback(onFrame)
138
+ frameScheduled = false
139
+ }
57
140
 
58
- init {
59
- setWillNotDraw(false)
141
+ private fun schedule() {
142
+ // No script means nothing to draw and no reason to hold a frame callback open. Paused playback
143
+ // still ticks, because a seek while paused has to move the signs with it.
144
+ if (frameScheduled || script == null || !isAttachedToWindow) return
145
+ frameScheduled = true
146
+ Choreographer.getInstance().postFrameCallback(onFrame)
60
147
  }
61
148
 
62
- override fun onCues(cueGroup: CueGroup) {
63
- setSigns(cueGroup.cues)
149
+ private fun tick() {
150
+ val script = this.script ?: return
151
+ val player = this.player ?: return
152
+ setActive(script.signsAt(player.contentPosition * 1000L))
64
153
  }
65
154
 
66
- private fun setSigns(cues: List<Cue>) {
67
- val next = cues.mapNotNull(::signOf)
68
- // Reference equality on the list contents is enough: the parser produces one AssSign per event
69
- // and hands out the same instance for every segment the event spans.
70
- if (next.size == signs.size && next.indices.all { next[it] === signs[it] }) return
71
- signs = next
155
+ private fun setActive(next: List<AssSign>) {
156
+ if (next.size == active.size && next.indices.all { next[it] === active[it] }) return
157
+ active = next
72
158
  rebuild()
73
159
  }
74
160
 
75
161
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
76
162
  super.onSizeChanged(w, h, oldw, oldh)
77
163
  // Every measurement below is in view pixels, so all of it is stale after a resize — which
78
- // happens on every rotation and on entering full screen.
164
+ // happens on rotation and on entering full screen.
165
+ layouts = HashMap()
79
166
  rebuild()
80
167
  }
81
168
 
82
- /**
83
- * Lays the signs out once per change rather than once per frame.
84
- *
85
- * Frame-by-frame typesetting changes the on-screen set every 40 ms, and `onDraw` can run more
86
- * often than that for reasons of the view system's own. Text layout is the expensive half of
87
- * drawing, and none of it depends on anything that varies between frames.
88
- */
169
+ /** Lays out only what is new on screen, and keeps the rest as it was. */
89
170
  private fun rebuild() {
90
- prepared = if (width == 0 || height == 0) emptyList() else signs.mapNotNull(::prepare)
91
- invalidate()
92
- }
171
+ if (width == 0 || height == 0) {
172
+ prepared = emptyList()
173
+ invalidate()
174
+ return
175
+ }
93
176
 
94
- fun attachTo(player: Player?) {
95
- if (this.player === player) return
96
- this.player?.removeListener(this)
97
- this.player = player
98
- player?.addListener(this)
99
- // A listener only hears about the *next* cue change, so seed from what is on screen already.
100
- setSigns(player?.currentCues?.cues ?: emptyList())
177
+ val next = HashMap<AssSign, PreparedSign>(active.size)
178
+ prepared = active.mapNotNull { sign ->
179
+ (layouts[sign] ?: prepare(sign))?.also { next[sign] = it }
180
+ }
181
+ layouts = next
182
+ invalidate()
101
183
  }
102
184
 
103
- fun detach() {
104
- player?.removeListener(this)
105
- player = null
106
- signs = emptyList()
107
- rebuild()
108
- }
185
+ // -----------------------------------------------------------------------------------------
186
+ // Drawing
187
+ // -----------------------------------------------------------------------------------------
109
188
 
110
189
  override fun onDraw(canvas: Canvas) {
111
190
  for (sign in prepared) {
112
- draw(canvas, sign)
191
+ canvas.save()
192
+ canvas.concat(sign.matrix)
193
+ canvas.translate(sign.left, sign.top)
194
+ sign.stroke?.draw(canvas)
195
+ sign.fill.draw(canvas)
196
+ canvas.restore()
113
197
  }
114
198
  }
115
199
 
116
- private fun draw(canvas: Canvas, sign: PreparedSign) {
117
- canvas.save()
118
- canvas.concat(sign.matrix)
119
- canvas.translate(sign.left, sign.top)
120
- sign.stroke?.draw(canvas)
121
- sign.fill.draw(canvas)
122
- canvas.restore()
123
- }
124
-
125
- /** Everything about one sign that does not change between frames. */
200
+ /** Everything about one sign that does not change while it is on screen. */
126
201
  private class PreparedSign(
127
202
  val matrix: Matrix,
128
203
  val left: Float,
@@ -141,17 +216,24 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
141
216
  if (alpha == 0) return null
142
217
 
143
218
  // A fresh paint per sign: StaticLayout keeps the reference it was built with, so a shared
144
- // mutable one would repaint every sign already laid out in this pass.
219
+ // mutable one would repaint every sign already laid out.
145
220
  val fillPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
146
221
  fillPaint.applySign(sign, scaleToViewY)
147
- fillPaint.color = Color.argb(alpha, Color.red(sign.colour or ALPHA_MASK), Color.green(sign.colour or ALPHA_MASK), Color.blue(sign.colour or ALPHA_MASK))
222
+ fillPaint.color = sign.colour.withAlpha(alpha)
148
223
 
149
224
  // Wrapping is the script's job, not ours: a sign is placed by hand and its line breaks are
150
225
  // written into the text as \N. A width of "as wide as it wants" reproduces that.
151
226
  val textWidth = Layout.getDesiredWidth(sign.text, fillPaint).toInt() + 1
227
+ // A multi-line sign wraps the way its own alignment says, not always centred: a left-anchored
228
+ // placard reads wrong with its second line pulled to the middle.
229
+ val blockAlignment = when ((sign.alignment - 1) % 3) {
230
+ 0 -> Layout.Alignment.ALIGN_NORMAL
231
+ 2 -> Layout.Alignment.ALIGN_OPPOSITE
232
+ else -> Layout.Alignment.ALIGN_CENTER
233
+ }
152
234
  val layout = StaticLayout.Builder
153
235
  .obtain(sign.text, 0, sign.text.length, fillPaint, textWidth)
154
- .setAlignment(Layout.Alignment.ALIGN_CENTER)
236
+ .setAlignment(blockAlignment)
155
237
  .setIncludePad(false)
156
238
  .build()
157
239
 
@@ -170,23 +252,31 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
170
252
  }
171
253
 
172
254
  // \org moves the centre of rotation away from the text, which is how a sign stays glued to a
173
- // placard that is itself pivoting off-screen. Without it every rotation would spin about the
174
- // text's own middle and drift off the artwork.
255
+ // placard pivoting off-screen. Without it every rotation would spin about the text's own middle
256
+ // and drift off the artwork.
175
257
  val pivotX = sign.pivotX * scaleToViewX
176
258
  val pivotY = sign.pivotY * scaleToViewY
177
259
 
178
- matrix.reset()
260
+ // Order matters, and it is not the order the calls read in. Android's `post*` methods
261
+ // pre-multiply — `M.postRotate(r)` maps a point as `R * (M * p)` — so the first call written is
262
+ // the first one applied to the glyph. ASS scales the glyph and *then* turns it, and with an
263
+ // anisotropic scale (this episode carries fscx 125.86 against fscy 120.19) the two do not
264
+ // commute, so getting it backwards changes the shape rather than just the arithmetic.
265
+ val matrix = Matrix()
266
+ matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
179
267
  if (sign.rotationX != 0f || sign.rotationY != 0f) {
268
+ val cameraMatrix = Matrix()
180
269
  camera.save()
181
270
  camera.rotateX(sign.rotationX)
182
271
  camera.rotateY(sign.rotationY)
183
- camera.getMatrix(matrix)
272
+ // `getMatrix` overwrites rather than concatenating, so it needs a matrix of its own.
273
+ camera.getMatrix(cameraMatrix)
184
274
  camera.restore()
185
- // Camera transforms about the origin, so the pivot has to be walked to it and back.
186
- matrix.preTranslate(-pivotX, -pivotY)
187
- matrix.postTranslate(pivotX, pivotY)
275
+ // The camera transforms about the origin; walk the pivot there and back.
276
+ cameraMatrix.preTranslate(-pivotX, -pivotY)
277
+ cameraMatrix.postTranslate(pivotX, pivotY)
278
+ matrix.postConcat(cameraMatrix)
188
279
  }
189
- matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
190
280
  matrix.postRotate(sign.rotationZ, pivotX, pivotY)
191
281
 
192
282
  val stroke = if (sign.outlineWidth <= 0f) null else {
@@ -194,17 +284,17 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
194
284
  strokePaint.style = Paint.Style.STROKE
195
285
  strokePaint.applySign(sign, scaleToViewY)
196
286
  strokePaint.strokeWidth = sign.outlineWidth * scaleToViewY * 2f
197
- strokePaint.color = Color.argb(alpha, Color.red(sign.outlineColour or ALPHA_MASK), Color.green(sign.outlineColour or ALPHA_MASK), Color.blue(sign.outlineColour or ALPHA_MASK))
287
+ strokePaint.color = sign.outlineColour.withAlpha(alpha)
198
288
  // The stroke is centred on the glyph edge, so half of it lands inside the letter. Drawing the
199
- // fill afterwards covers that half and leaves the outline the width the script asked for.
289
+ // fill over it leaves the outline the width the script asked for.
200
290
  StaticLayout.Builder
201
291
  .obtain(sign.text, 0, sign.text.length, strokePaint, textWidth)
202
- .setAlignment(Layout.Alignment.ALIGN_CENTER)
292
+ .setAlignment(blockAlignment)
203
293
  .setIncludePad(false)
204
294
  .build()
205
295
  }
206
296
 
207
- return PreparedSign(Matrix(matrix), left, top, layout, stroke)
297
+ return PreparedSign(matrix, left, top, layout, stroke)
208
298
  }
209
299
 
210
300
  private fun TextPaint.applySign(sign: AssSign, scaleToViewY: Float) {
@@ -221,22 +311,15 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
221
311
  isUnderlineText = sign.underline
222
312
  }
223
313
 
224
- companion object {
225
- /** ASS colours are stored without an alpha byte; opacity travels separately. */
226
- private const val ALPHA_MASK = 0xFF000000.toInt()
314
+ /** ASS colours carry no alpha byte; opacity travels separately. */
315
+ private fun Int.withAlpha(alpha: Int): Int {
316
+ val opaque = this or OPAQUE
317
+ return Color.argb(alpha, Color.red(opaque), Color.green(opaque), Color.blue(opaque))
318
+ }
227
319
 
228
- private fun signOf(cue: Cue): AssSign? {
229
- val text = cue.text as? android.text.Spanned ?: return null
230
- return text.getSpans(0, text.length, AssSignSpan::class.java).firstOrNull()?.sign
231
- }
320
+ companion object {
321
+ private const val OPAQUE = 0xFF000000.toInt()
232
322
 
233
- /**
234
- * Inserts the overlay in front of `PlayerView`'s subtitle view, or returns the one already
235
- * there.
236
- *
237
- * Returns null when the player view has no subtitle view — a configuration this package does
238
- * not produce, but `PlayerView` allows.
239
- */
240
323
  /** Removes the overlay from [playerView], dropping its listener registration with it. */
241
324
  fun remove(playerView: PlayerView) {
242
325
  val parent = playerView.subtitleView?.parent as? ViewGroup ?: return
@@ -248,6 +331,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
248
331
  }
249
332
  }
250
333
 
334
+ /**
335
+ * Inserts the overlay in front of `PlayerView`'s subtitle view, or returns the one already
336
+ * there.
337
+ *
338
+ * Returns null when the player view has no subtitle view — a configuration this package does not
339
+ * produce, but `PlayerView` allows.
340
+ */
251
341
  fun install(playerView: PlayerView): AssSignOverlay? {
252
342
  val subtitleView: SubtitleView = playerView.subtitleView ?: return null
253
343
  val parent = subtitleView.parent as? ViewGroup ?: return null
@@ -257,12 +347,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
257
347
  }
258
348
 
259
349
  val overlay = AssSignOverlay(playerView.context)
260
- // Directly in front of the captions: a sign belongs to the picture, and the dialogue box
261
- // should not be able to cover it.
262
- parent.addView(overlay, parent.indexOfChild(subtitleView) + 1, ViewGroup.LayoutParams(
263
- ViewGroup.LayoutParams.MATCH_PARENT,
264
- ViewGroup.LayoutParams.MATCH_PARENT,
265
- ))
350
+ // Directly in front of the captions: a sign belongs to the picture, and the dialogue box must
351
+ // not be able to cover it.
352
+ parent.addView(
353
+ overlay,
354
+ parent.indexOfChild(subtitleView) + 1,
355
+ ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
356
+ )
266
357
  return overlay
267
358
  }
268
359
  }
@@ -37,8 +37,15 @@ internal object AssSignParser {
37
37
  private const val DEFAULT_PLAY_RES_X = 384f
38
38
  private const val DEFAULT_PLAY_RES_Y = 288f
39
39
 
40
- /** Matches one override block, `{…}`. The contents are scanned separately. */
41
- private val BRACES = Regex("""\{([^}]*)}""")
40
+ /**
41
+ * Matches one override block, `{}`. The contents are scanned separately.
42
+ *
43
+ * Both braces are escaped. A bare closing brace is legal in `java.util.regex` and a syntax error
44
+ * in ICU, which is the engine Android actually uses — so the unescaped form compiles and passes
45
+ * on a JVM test run, then throws `PatternSyntaxException` from a static initialiser on device and
46
+ * takes the playback thread down with it.
47
+ */
48
+ private val BRACES = Regex("""\{([^}]*)\}""")
42
49
 
43
50
  /**
44
51
  * Splits a script into the part this package draws and the part Media3 draws.
@@ -225,13 +232,21 @@ internal object AssSignParser {
225
232
  )
226
233
  }
227
234
 
228
- /** `H:MM:SS.cc`, centiseconds. */
235
+ /**
236
+ * `H:MM:SS.cc` or `MM:SS.cc`, centiseconds.
237
+ *
238
+ * The hour is optional because Media3's own `SSA_TIMECODE_PATTERN` makes it optional. Requiring it
239
+ * was worse than a parse failure: an event this rejected was never classified, so it stayed in the
240
+ * copy handed to Media3 — whose `SsaStyle.Overrides` reads `\pos` perfectly well and would draw
241
+ * the sign flat and unrotated, which is the exact failure this whole subsystem exists to remove,
242
+ * with nothing in the logs to say it had happened.
243
+ */
229
244
  private fun parseTimeUs(value: String): Long? {
230
245
  val parts = value.split(':')
231
- if (parts.size != 3) return null
232
- val h = parts[0].trim().toLongOrNull() ?: return null
233
- val m = parts[1].trim().toLongOrNull() ?: return null
234
- val s = parts[2].trim().toDoubleOrNull() ?: return null
246
+ if (parts.size !in 2..3) return null
247
+ val h = if (parts.size == 3) parts[0].trim().toLongOrNull() ?: return null else 0L
248
+ val m = parts[parts.size - 2].trim().toLongOrNull() ?: return null
249
+ val s = parts[parts.size - 1].trim().toDoubleOrNull() ?: return null
235
250
  return ((h * 3600 + m * 60) * 1_000_000L) + (s * 1_000_000.0).toLong()
236
251
  }
237
252
 
@@ -493,11 +508,39 @@ internal data class AssSign(
493
508
  val playResY: Float,
494
509
  )
495
510
 
496
- internal data class AssScript(
511
+ internal class AssScript(
497
512
  val playResX: Float,
498
513
  val playResY: Float,
499
514
  /** Typeset events, drawn by [AssSignOverlay]. */
500
515
  val signs: List<AssSign>,
501
516
  /** The script with every sign, drawing and invisible event cut out, for Media3's own parser. */
502
517
  val dialogueOnly: String,
503
- )
518
+ ) {
519
+ /**
520
+ * Signs bucketed by the whole second they are on screen during, so the overlay can answer "what is
521
+ * showing now" once per frame without walking twelve thousand events.
522
+ *
523
+ * A bucket per second rather than a sorted sweep because playback is not monotonic — every seek
524
+ * would otherwise need a rebuild — and because frame-by-frame typesetting is almost entirely
525
+ * events shorter than one frame, so nearly every sign lands in exactly one bucket.
526
+ */
527
+ val signsBySecond: Map<Long, List<AssSign>> by lazy {
528
+ val buckets = HashMap<Long, MutableList<AssSign>>()
529
+ for (sign in signs) {
530
+ val first = sign.startUs / 1_000_000L
531
+ // `endUs` is exclusive, so an event ending exactly on a second boundary does not reach into
532
+ // the next bucket.
533
+ val last = (sign.endUs - 1).coerceAtLeast(sign.startUs) / 1_000_000L
534
+ for (second in first..last) {
535
+ buckets.getOrPut(second) { ArrayList(4) }.add(sign)
536
+ }
537
+ }
538
+ buckets
539
+ }
540
+
541
+ /** The signs on screen at [positionUs], in file order. */
542
+ fun signsAt(positionUs: Long): List<AssSign> {
543
+ val bucket = signsBySecond[positionUs / 1_000_000L] ?: return emptyList()
544
+ return bucket.filter { positionUs >= it.startUs && positionUs < it.endUs }
545
+ }
546
+ }
@@ -0,0 +1,58 @@
1
+ package expo.modules.video.utils
2
+
3
+ /**
4
+ * Hands parsed ASS typesetting from the subtitle parser to [AssSignOverlay].
5
+ *
6
+ * ### Why not the cue stream
7
+ *
8
+ * The obvious route — wrap each sign in a span on a cue and let Media3's timing carry it — does not
9
+ * survive the trip. `SubtitleExtractor` writes every `CuesWithTiming` into the sample queue through
10
+ * `CueEncoder`, which calls `Cue.toSerializableBundle()` and then `Parcel.marshall()`. Spans are
11
+ * only preserved if they are one of the three Media3 declares in `CustomSpanBundler` or a framework
12
+ * `ParcelableSpan`; anything else is dropped without a word. A custom span arrives on the other side
13
+ * as plain text with nothing attached, so the overlay saw no signs at all — no crash, no log, just
14
+ * an empty screen where the typesetting should be.
15
+ *
16
+ * Two further reasons not to go back to it even if a span could be smuggled through. A typeset
17
+ * episode holds 12,616 sign events, which would put ~26,000 boundaries into
18
+ * [StackingSubtitleParser]'s timeline pass — an O(n²) sweep over every entry at every boundary — and
19
+ * then write ~26,000 samples into the queue. Keeping signs out of the cue stream leaves the
20
+ * dialogue-only file at a few hundred events, which is the size that pass was written for.
21
+ *
22
+ * ### The key
23
+ *
24
+ * `Format.id`, which `SubtitleSource.toSubtitleConfiguration` sets to
25
+ * `expo-subtitle:<language>:<uri>` — stable, unique per track, and readable from both ends: the
26
+ * parser gets it from the `Format` its factory was given, and the overlay reads it off the selected
27
+ * text track. Without a match the overlay draws nothing rather than guessing, because guessing means
28
+ * drawing one language's typesetting over another's.
29
+ */
30
+ internal object AssSignStore {
31
+
32
+ /**
33
+ * Enough for the track being watched plus a few the viewer switched away from. Each entry holds
34
+ * every sign in an episode — 12,616 in the file this was built against — so this is not a cache to
35
+ * let grow.
36
+ */
37
+ private const val MAX_TRACKS = 4
38
+
39
+ private val scripts = object : LinkedHashMap<String, AssScript>(MAX_TRACKS, 0.75f, true) {
40
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, AssScript>): Boolean =
41
+ size > MAX_TRACKS
42
+ }
43
+
44
+ /** Called from the extractor's loading thread. */
45
+ @Synchronized
46
+ fun put(trackId: String, script: AssScript) {
47
+ scripts[trackId] = script
48
+ }
49
+
50
+ /** Called from the main thread, once per track change. */
51
+ @Synchronized
52
+ fun get(trackId: String?): AssScript? = trackId?.let { scripts[it] }
53
+
54
+ @Synchronized
55
+ fun clear() {
56
+ scripts.clear()
57
+ }
58
+ }
@@ -191,9 +191,6 @@ class RoundedSubtitleBackground private constructor(private val backgroundView:
191
191
  */
192
192
  private fun toBackgroundCue(cue: Cue): Cue? {
193
193
  val text = cue.text ?: return null // Bitmap cues carry no text to put a box behind.
194
- // A sign's cue is a zero-width placeholder standing in for text drawn by `AssSignOverlay`.
195
- // There is no glyph here to sit behind, and the box would be the only thing on screen.
196
- if (text is Spanned && text.getSpans(0, text.length, AssSignSpan::class.java).isNotEmpty()) return null
197
194
  val stripped = if (keepMetricSpans && text is Spanned) {
198
195
  SpannableString(text).also { spannable ->
199
196
  for (span in spannable.getSpans(0, spannable.length, Any::class.java)) {
@@ -1,14 +1,13 @@
1
1
  package expo.modules.video.utils
2
2
 
3
- import android.text.SpannableString
4
3
  import android.text.SpannableStringBuilder
5
- import android.text.Spanned
6
4
  import androidx.annotation.OptIn
7
5
  import androidx.media3.common.C
8
6
  import androidx.media3.common.Format
9
7
  import androidx.media3.common.MimeTypes
10
8
  import androidx.media3.common.text.Cue
11
9
  import androidx.media3.common.util.Consumer
10
+ import androidx.media3.common.util.Log
12
11
  import androidx.media3.common.util.UnstableApi
13
12
  import androidx.media3.extractor.text.CuesWithTiming
14
13
  import androidx.media3.extractor.text.DefaultSubtitleParserFactory
@@ -76,24 +75,14 @@ open class StackingSubtitleParser(
76
75
  ) : SubtitleParser {
77
76
 
78
77
  /**
79
- * A chance for a subclass to take some events out of the delegate's hands and supply its own cues
80
- * for them. Returns the bytes the delegate should parse plus the cues the subclass is drawing.
81
- *
82
- * Default is to change nothing.
78
+ * A chance for a subclass to hand the delegate something other than the bytes it was given
79
+ * [StackingSsaParser] removes the typeset events it draws itself. Default is to change nothing.
83
80
  */
84
81
  protected open fun split(data: ByteArray, offset: Int, length: Int): Split =
85
- Split(data, offset, length, emptyList())
86
-
87
- /** [split]'s result: what the delegate parses, and what the subclass contributes. */
88
- protected class Split(
89
- val data: ByteArray,
90
- val offset: Int,
91
- val length: Int,
92
- val ownCues: List<PreparedCue>,
93
- )
82
+ Split(data, offset, length)
94
83
 
95
- /** One cue a subclass supplies, with the timing the delegate would otherwise have derived. */
96
- protected class PreparedCue(val cue: Cue, val startUs: Long, val endUs: Long)
84
+ /** [split]'s result: the bytes the delegate should parse. */
85
+ protected class Split(val data: ByteArray, val offset: Int, val length: Int)
97
86
 
98
87
  // Every emitted segment is a complete description of what should be on screen for its interval,
99
88
  // so segments replace each other rather than being merged by the renderer.
@@ -112,9 +101,6 @@ open class StackingSubtitleParser(
112
101
  ) {
113
102
  val entries = mutableListOf<Entry>()
114
103
  val split = split(data, offset, length)
115
- for (prepared in split.ownCues) {
116
- entries.add(Entry(prepared.cue, prepared.startUs, prepared.endUs, entries.size))
117
- }
118
104
  // Always ask the delegate for everything; `outputOptions` is applied to the merged result below,
119
105
  // because a segment can span cues that fall on both sides of the requested start time.
120
106
  delegate.parse(split.data, split.offset, split.length, SubtitleParser.OutputOptions.allCues()) { cuesWithTiming ->
@@ -319,48 +305,58 @@ class StackingSubripParser : StackingSubtitleParser(SubripParser())
319
305
  /**
320
306
  * ASS and SSA.
321
307
  *
322
- * Splits the script in two before anything else runs. [AssSignParser] takes the typeset events —
323
- * anything the author placed — and the delegate gets a copy with those lines removed, so the two
324
- * renderers can never draw the same event. Signs come back as cues carrying an [AssSignSpan], which
325
- * rides Media3's own timing all the way to [AssSignOverlay]; the cue text itself is a zero-width
326
- * character, so `SubtitleView` lays it out and draws nothing.
308
+ * Splits the script before anything else runs. [AssSignParser] takes the typeset events — anything
309
+ * the author placed — and the delegate gets a copy with those lines removed, so the two renderers
310
+ * can never draw the same event. The signs go to [AssSignStore] for [AssSignOverlay] to pick up;
311
+ * they deliberately do **not** ride the cue stream, because a custom span does not survive
312
+ * `CueEncoder`'s trip through `Parcel` and because twelve thousand of them would swamp the timeline
313
+ * pass above. See [AssSignStore] for the whole reasoning.
327
314
  *
328
- * Falls back to plain delegation when the script yields no signs, which is every `.ass` that is
329
- * only dialogue, and when the bytes are not decodable as text.
315
+ * Falls back to plain delegation when the script yields no signs, which is every `.ass` that is only
316
+ * dialogue, and when anything at all goes wrong.
330
317
  *
331
318
  * The delegate is built by the caller rather than constructed here because an SSA track muxed into
332
319
  * a container carries its `[V4+ Styles]` header in the format's initialization data, and only
333
320
  * `DefaultSubtitleParserFactory` knows to pass it on. A parser built without it resolves no styles.
334
321
  */
335
322
  @OptIn(UnstableApi::class)
336
- class StackingSsaParser(delegate: SubtitleParser) :
337
- StackingSubtitleParser(delegate, SsaCueNormalizer::normalize) {
323
+ class StackingSsaParser(
324
+ delegate: SubtitleParser,
325
+ /**
326
+ * `Format.id` of the track being parsed, which is how the overlay finds these signs again. Null
327
+ * means no lookup key, so the signs are parsed out of the delegate's copy but never drawn — the
328
+ * captions stay correct, the typesetting is simply absent.
329
+ */
330
+ private val trackId: String?,
331
+ ) : StackingSubtitleParser(delegate, SsaCueNormalizer::normalize) {
338
332
 
339
333
  override fun split(data: ByteArray, offset: Int, length: Int): Split {
340
- val content = runCatching { String(data, offset, length, Charsets.UTF_8) }.getOrNull()
341
- ?: return Split(data, offset, length, emptyList())
342
-
343
- val script = AssSignParser.parse(content)
344
- if (script.signs.isEmpty()) return Split(data, offset, length, emptyList())
345
-
346
- val dialogue = script.dialogueOnly.toByteArray(Charsets.UTF_8)
347
- return Split(dialogue, 0, dialogue.size, script.signs.map(::toEntry))
334
+ // Nothing in here is worth a crash, and the whole body is inside the guard rather than just the
335
+ // parse. This runs on the extractor's loading thread, where a thrown exception is not a failed
336
+ // subtitle — it takes playback down with it, which is how a stray `}` in a regex turned into a
337
+ // dead player on an episode that had been playing fine.
338
+ //
339
+ // A subtitle file is untrusted input from a scraper. The honest failure for one this cannot
340
+ // read is captions drawn Media3's own way, flat, rather than no video at all.
341
+ return runCatching {
342
+ if (trackId == null) return@runCatching Split(data, offset, length)
343
+
344
+ val script = AssSignParser.parse(String(data, offset, length, Charsets.UTF_8))
345
+ if (script.signs.isEmpty()) {
346
+ Split(data, offset, length)
347
+ } else {
348
+ AssSignStore.put(trackId, script)
349
+ val dialogue = script.dialogueOnly.toByteArray(Charsets.UTF_8)
350
+ Split(dialogue, 0, dialogue.size)
351
+ }
352
+ }.getOrElse { error ->
353
+ Log.w(TAG, "Sign rendering unavailable for this track; falling back to Media3", error)
354
+ Split(data, offset, length)
355
+ }
348
356
  }
349
357
 
350
- private fun toEntry(sign: AssSign): PreparedCue {
351
- val text = SpannableString(SIGN_CUE_PLACEHOLDER)
352
- text.setSpan(AssSignSpan(sign), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
353
-
354
- // Positioned so the stacking pass leaves it alone and the span filter keeps it intact. The
355
- // values are only a rough placement for the invisible placeholder; the overlay reads the real
356
- // coordinates off the span.
357
- val cue = Cue.Builder()
358
- .setText(text)
359
- .setPosition((sign.x / sign.playResX).coerceIn(0f, 1f))
360
- .setLine((sign.y / sign.playResY).coerceIn(0f, 1f), Cue.LINE_TYPE_FRACTION)
361
- .build()
362
-
363
- return PreparedCue(cue, sign.startUs, sign.endUs)
358
+ private companion object {
359
+ const val TAG = "StackingSsaParser"
364
360
  }
365
361
  }
366
362
 
@@ -391,7 +387,7 @@ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
391
387
  // reliably a sign — typeset to sit on the artwork, in colours chosen to match it — rather than
392
388
  // dialogue that happens to carry a cue setting.
393
389
  format.isSsa() -> FilteringSubtitleParser(
394
- StackingSsaParser(defaultFactory.create(format)),
390
+ StackingSsaParser(defaultFactory.create(format), format.id),
395
391
  keepAppearanceOfPositionedCues = true,
396
392
  )
397
393
  else -> FilteringSubtitleParser(defaultFactory.create(format))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-video-subtitle",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "A cross-platform, performant video component for React Native and Expo with external subtitle sideloading and custom subtitle styling.",
5
5
  "keywords": [
6
6
  "expo",
@@ -1,23 +0,0 @@
1
- package expo.modules.video.utils
2
-
3
- /**
4
- * Carries one parsed ASS sign through Media3's cue stream to [AssSignOverlay].
5
- *
6
- * The cue itself is a single zero-width character, so `SubtitleView` lays it out and draws nothing
7
- * while the overlay reads the sign off this span and draws it properly. Riding the cue stream is
8
- * what buys the timing for free: Media3 already decides which cues are on screen at each moment,
9
- * and a sign is on screen exactly as long as its cue is.
10
- *
11
- * A marker span rather than a field on `Cue` because `Cue` is final and carries no room for one —
12
- * it has `shearDegrees` and nothing else transform-shaped, and `SubtitlePainter` ignores even that.
13
- */
14
- internal class AssSignSpan(val sign: AssSign)
15
-
16
- /**
17
- * The cue text a sign rides on.
18
- *
19
- * A zero-width space still occupies a line of height in `SubtitleView`, which is harmless for a
20
- * positioned cue and keeps the span anchored to something. Distinct from [HELD_SLOT_PLACEHOLDER]
21
- * only in intent; both are invisible.
22
- */
23
- internal const val SIGN_CUE_PLACEHOLDER = "​"