expo-video-subtitle 0.7.1 → 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,38 @@ 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
+
10
42
  ## 0.7.1
11
43
 
12
44
  ### Fixed
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 work — Android 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,16 +216,16 @@ 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
152
- // A multi-line sign wraps its lines the way its own alignment says, not always centred: a
153
- // left-anchored placard reads wrong with its second line pulled to the middle.
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.
154
229
  val blockAlignment = when ((sign.alignment - 1) % 3) {
155
230
  0 -> Layout.Alignment.ALIGN_NORMAL
156
231
  2 -> Layout.Alignment.ALIGN_OPPOSITE
@@ -177,23 +252,31 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
177
252
  }
178
253
 
179
254
  // \org moves the centre of rotation away from the text, which is how a sign stays glued to a
180
- // placard that is itself pivoting off-screen. Without it every rotation would spin about the
181
- // 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.
182
257
  val pivotX = sign.pivotX * scaleToViewX
183
258
  val pivotY = sign.pivotY * scaleToViewY
184
259
 
185
- 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)
186
267
  if (sign.rotationX != 0f || sign.rotationY != 0f) {
268
+ val cameraMatrix = Matrix()
187
269
  camera.save()
188
270
  camera.rotateX(sign.rotationX)
189
271
  camera.rotateY(sign.rotationY)
190
- camera.getMatrix(matrix)
272
+ // `getMatrix` overwrites rather than concatenating, so it needs a matrix of its own.
273
+ camera.getMatrix(cameraMatrix)
191
274
  camera.restore()
192
- // Camera transforms about the origin, so the pivot has to be walked to it and back.
193
- matrix.preTranslate(-pivotX, -pivotY)
194
- 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)
195
279
  }
196
- matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
197
280
  matrix.postRotate(sign.rotationZ, pivotX, pivotY)
198
281
 
199
282
  val stroke = if (sign.outlineWidth <= 0f) null else {
@@ -201,9 +284,9 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
201
284
  strokePaint.style = Paint.Style.STROKE
202
285
  strokePaint.applySign(sign, scaleToViewY)
203
286
  strokePaint.strokeWidth = sign.outlineWidth * scaleToViewY * 2f
204
- 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)
205
288
  // The stroke is centred on the glyph edge, so half of it lands inside the letter. Drawing the
206
- // 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.
207
290
  StaticLayout.Builder
208
291
  .obtain(sign.text, 0, sign.text.length, strokePaint, textWidth)
209
292
  .setAlignment(blockAlignment)
@@ -211,7 +294,7 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
211
294
  .build()
212
295
  }
213
296
 
214
- return PreparedSign(Matrix(matrix), left, top, layout, stroke)
297
+ return PreparedSign(matrix, left, top, layout, stroke)
215
298
  }
216
299
 
217
300
  private fun TextPaint.applySign(sign: AssSign, scaleToViewY: Float) {
@@ -228,22 +311,15 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
228
311
  isUnderlineText = sign.underline
229
312
  }
230
313
 
231
- companion object {
232
- /** ASS colours are stored without an alpha byte; opacity travels separately. */
233
- 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
+ }
234
319
 
235
- private fun signOf(cue: Cue): AssSign? {
236
- val text = cue.text as? android.text.Spanned ?: return null
237
- return text.getSpans(0, text.length, AssSignSpan::class.java).firstOrNull()?.sign
238
- }
320
+ companion object {
321
+ private const val OPAQUE = 0xFF000000.toInt()
239
322
 
240
- /**
241
- * Inserts the overlay in front of `PlayerView`'s subtitle view, or returns the one already
242
- * there.
243
- *
244
- * Returns null when the player view has no subtitle view — a configuration this package does
245
- * not produce, but `PlayerView` allows.
246
- */
247
323
  /** Removes the overlay from [playerView], dropping its listener registration with it. */
248
324
  fun remove(playerView: PlayerView) {
249
325
  val parent = playerView.subtitleView?.parent as? ViewGroup ?: return
@@ -255,6 +331,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
255
331
  }
256
332
  }
257
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
+ */
258
341
  fun install(playerView: PlayerView): AssSignOverlay? {
259
342
  val subtitleView: SubtitleView = playerView.subtitleView ?: return null
260
343
  val parent = subtitleView.parent as? ViewGroup ?: return null
@@ -264,12 +347,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
264
347
  }
265
348
 
266
349
  val overlay = AssSignOverlay(playerView.context)
267
- // Directly in front of the captions: a sign belongs to the picture, and the dialogue box
268
- // should not be able to cover it.
269
- parent.addView(overlay, parent.indexOfChild(subtitleView) + 1, ViewGroup.LayoutParams(
270
- ViewGroup.LayoutParams.MATCH_PARENT,
271
- ViewGroup.LayoutParams.MATCH_PARENT,
272
- ))
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
+ )
273
357
  return overlay
274
358
  }
275
359
  }
@@ -232,13 +232,21 @@ internal object AssSignParser {
232
232
  )
233
233
  }
234
234
 
235
- /** `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
+ */
236
244
  private fun parseTimeUs(value: String): Long? {
237
245
  val parts = value.split(':')
238
- if (parts.size != 3) return null
239
- val h = parts[0].trim().toLongOrNull() ?: return null
240
- val m = parts[1].trim().toLongOrNull() ?: return null
241
- 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
242
250
  return ((h * 3600 + m * 60) * 1_000_000L) + (s * 1_000_000.0).toLong()
243
251
  }
244
252
 
@@ -500,11 +508,39 @@ internal data class AssSign(
500
508
  val playResY: Float,
501
509
  )
502
510
 
503
- internal data class AssScript(
511
+ internal class AssScript(
504
512
  val playResX: Float,
505
513
  val playResY: Float,
506
514
  /** Typeset events, drawn by [AssSignOverlay]. */
507
515
  val signs: List<AssSign>,
508
516
  /** The script with every sign, drawing and invisible event cut out, for Media3's own parser. */
509
517
  val dialogueOnly: String,
510
- )
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,8 +1,6 @@
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
@@ -77,24 +75,14 @@ open class StackingSubtitleParser(
77
75
  ) : SubtitleParser {
78
76
 
79
77
  /**
80
- * A chance for a subclass to take some events out of the delegate's hands and supply its own cues
81
- * for them. Returns the bytes the delegate should parse plus the cues the subclass is drawing.
82
- *
83
- * 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.
84
80
  */
85
81
  protected open fun split(data: ByteArray, offset: Int, length: Int): Split =
86
- Split(data, offset, length, emptyList())
87
-
88
- /** [split]'s result: what the delegate parses, and what the subclass contributes. */
89
- protected class Split(
90
- val data: ByteArray,
91
- val offset: Int,
92
- val length: Int,
93
- val ownCues: List<PreparedCue>,
94
- )
82
+ Split(data, offset, length)
95
83
 
96
- /** One cue a subclass supplies, with the timing the delegate would otherwise have derived. */
97
- 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)
98
86
 
99
87
  // Every emitted segment is a complete description of what should be on screen for its interval,
100
88
  // so segments replace each other rather than being merged by the renderer.
@@ -113,9 +101,6 @@ open class StackingSubtitleParser(
113
101
  ) {
114
102
  val entries = mutableListOf<Entry>()
115
103
  val split = split(data, offset, length)
116
- for (prepared in split.ownCues) {
117
- entries.add(Entry(prepared.cue, prepared.startUs, prepared.endUs, entries.size))
118
- }
119
104
  // Always ask the delegate for everything; `outputOptions` is applied to the merged result below,
120
105
  // because a segment can span cues that fall on both sides of the requested start time.
121
106
  delegate.parse(split.data, split.offset, split.length, SubtitleParser.OutputOptions.allCues()) { cuesWithTiming ->
@@ -320,64 +305,59 @@ class StackingSubripParser : StackingSubtitleParser(SubripParser())
320
305
  /**
321
306
  * ASS and SSA.
322
307
  *
323
- * Splits the script in two before anything else runs. [AssSignParser] takes the typeset events —
324
- * anything the author placed — and the delegate gets a copy with those lines removed, so the two
325
- * renderers can never draw the same event. Signs come back as cues carrying an [AssSignSpan], which
326
- * rides Media3's own timing all the way to [AssSignOverlay]; the cue text itself is a zero-width
327
- * 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.
328
314
  *
329
- * Falls back to plain delegation when the script yields no signs, which is every `.ass` that is
330
- * 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.
331
317
  *
332
318
  * The delegate is built by the caller rather than constructed here because an SSA track muxed into
333
319
  * a container carries its `[V4+ Styles]` header in the format's initialization data, and only
334
320
  * `DefaultSubtitleParserFactory` knows to pass it on. A parser built without it resolves no styles.
335
321
  */
336
322
  @OptIn(UnstableApi::class)
337
- class StackingSsaParser(delegate: SubtitleParser) :
338
- 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) {
339
332
 
340
333
  override fun split(data: ByteArray, offset: Int, length: Int): Split {
341
334
  // Nothing in here is worth a crash, and the whole body is inside the guard rather than just the
342
- // parse. This runs on the extractor's loading thread, where an exception does not fail the
335
+ // parse. This runs on the extractor's loading thread, where a thrown exception is not a failed
343
336
  // subtitle — it takes playback down with it, which is how a stray `}` in a regex turned into a
344
337
  // dead player on an episode that had been playing fine.
345
338
  //
346
339
  // A subtitle file is untrusted input from a scraper. The honest failure for one this cannot
347
340
  // read is captions drawn Media3's own way, flat, rather than no video at all.
348
341
  return runCatching {
342
+ if (trackId == null) return@runCatching Split(data, offset, length)
343
+
349
344
  val script = AssSignParser.parse(String(data, offset, length, Charsets.UTF_8))
350
345
  if (script.signs.isEmpty()) {
351
- Split(data, offset, length, emptyList())
346
+ Split(data, offset, length)
352
347
  } else {
348
+ AssSignStore.put(trackId, script)
353
349
  val dialogue = script.dialogueOnly.toByteArray(Charsets.UTF_8)
354
- Split(dialogue, 0, dialogue.size, script.signs.map(::toEntry))
350
+ Split(dialogue, 0, dialogue.size)
355
351
  }
356
352
  }.getOrElse { error ->
357
353
  Log.w(TAG, "Sign rendering unavailable for this track; falling back to Media3", error)
358
- Split(data, offset, length, emptyList())
354
+ Split(data, offset, length)
359
355
  }
360
356
  }
361
357
 
362
358
  private companion object {
363
359
  const val TAG = "StackingSsaParser"
364
360
  }
365
-
366
- private fun toEntry(sign: AssSign): PreparedCue {
367
- val text = SpannableString(SIGN_CUE_PLACEHOLDER)
368
- text.setSpan(AssSignSpan(sign), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
369
-
370
- // Positioned so the stacking pass leaves it alone and the span filter keeps it intact. The
371
- // values are only a rough placement for the invisible placeholder; the overlay reads the real
372
- // coordinates off the span.
373
- val cue = Cue.Builder()
374
- .setText(text)
375
- .setPosition((sign.x / sign.playResX).coerceIn(0f, 1f))
376
- .setLine((sign.y / sign.playResY).coerceIn(0f, 1f), Cue.LINE_TYPE_FRACTION)
377
- .build()
378
-
379
- return PreparedCue(cue, sign.startUs, sign.endUs)
380
- }
381
361
  }
382
362
 
383
363
  /**
@@ -407,7 +387,7 @@ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
407
387
  // reliably a sign — typeset to sit on the artwork, in colours chosen to match it — rather than
408
388
  // dialogue that happens to carry a cue setting.
409
389
  format.isSsa() -> FilteringSubtitleParser(
410
- StackingSsaParser(defaultFactory.create(format)),
390
+ StackingSsaParser(defaultFactory.create(format), format.id),
411
391
  keepAppearanceOfPositionedCues = true,
412
392
  )
413
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.1",
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 = "​"