expo-video-subtitle 0.7.1 → 0.7.3

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,57 @@ 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.3
11
+
12
+ Hardening only. Nothing here changes what 0.7.2 draws when 0.7.2 works.
13
+
14
+ ### Changed
15
+
16
+ - **The sign store falls back to the only script it holds when the track id does not match.** The
17
+ parser reads `Format.id` from the `Format` its factory was handed; the overlay reads it from the
18
+ selected text track. Those should be the same value, but if they ever are not, the failure is
19
+ silent and total — signs parse correctly and never appear, which is precisely how the previous two
20
+ delivery attempts failed. With one track loaded there is nothing to be ambiguous about. With
21
+ several it still declines, because a wrong guess would draw one language's typesetting over
22
+ another's.
23
+
24
+ - **The overlay logs what it resolved.** Every way this subsystem has failed so far has been quiet:
25
+ a dropped span, a rejected timestamp, an id that did not match. One line under the `AssSignOverlay`
26
+ tag now says which text track was selected and how many signs were found for it, so the next
27
+ failure is one `logcat` away instead of a bisect.
28
+
29
+ ## 0.7.2
30
+
31
+ ### Fixed
32
+
33
+ - **Signs did not render at all (Android).** 0.7.0 handed each sign to the overlay on a custom span
34
+ attached to a cue. Nothing survives that trip: `SubtitleExtractor` writes every `CuesWithTiming`
35
+ into the sample queue through `CueEncoder`, which calls `Cue.toSerializableBundle()` and then
36
+ `Parcel.marshall()`, and a span is only preserved if it is one of the three Media3 declares in
37
+ `CustomSpanBundler` or a framework `ParcelableSpan`. Anything else is dropped silently. The cue
38
+ arrived on the other side as the zero-width placeholder with nothing attached, so the overlay found
39
+ no signs — no crash, no log, just missing typesetting while the dialogue rendered normally.
40
+
41
+ Signs now go to `AssSignStore`, keyed by the track's `Format.id`, and the overlay reads the
42
+ player's position on each frame to decide what is on screen. That also removes two problems the
43
+ cue-stream route carried with it: 12,616 sign events would have put ~26,000 boundaries through the
44
+ timeline pass in `StackingSubtitleParser` — an O(n²) sweep over every entry at every boundary — and
45
+ written ~26,000 samples into the queue. The dialogue-only file left for Media3 holds 377 events.
46
+
47
+ - **Scale was applied between the two rotations instead of before both.** Android's `Matrix.post*`
48
+ methods pre-multiply, so the calls apply in the order they are written, and the camera step ran
49
+ first. ASS scales a glyph and then turns it; with an anisotropic scale — this episode carries
50
+ `\fscx125.86` against `\fscy120.19` — the two do not commute, so the shape itself was wrong.
51
+
52
+ - **The overlay re-laid-out every sign whenever any one of them changed.** Frame-by-frame typesetting
53
+ changes the on-screen set every 40 ms, so a scene holding ten placards rebuilt all ten `StaticLayout`s
54
+ on every tick. Layouts are now kept by sign identity and only new signs are laid out.
55
+
56
+ - **A timestamp with no hour fell through to flat rendering.** `MM:SS.cc` is legal — Media3's own
57
+ `SSA_TIMECODE_PATTERN` makes the hour optional — but this parser required it and skipped the event,
58
+ which then stayed in the copy handed to Media3. Media3 reads `\pos` perfectly well and drew the
59
+ sign flat, with nothing in the logs to say it had happened.
60
+
10
61
  ## 0.7.1
11
62
 
12
63
  ### 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,14 @@ 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
21
+ import androidx.media3.common.util.Log
20
22
  import androidx.media3.common.util.UnstableApi
21
23
  import androidx.media3.ui.PlayerView
22
24
  import androidx.media3.ui.SubtitleView
@@ -25,9 +27,10 @@ import androidx.media3.ui.SubtitleView
25
27
  * Draws ASS typesetting — signs, placards, on-screen labels — with the transforms the script asks
26
28
  * for.
27
29
  *
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.
30
+ * `SubtitleView` cannot: `SubtitlePainter` has no call to `rotate`, `skew`, `Matrix` or `Camera`,
31
+ * and `Cue` has no rotation field, only `shearDegrees`, which the painter ignores. So
32
+ * [AssSignParser] takes those events out of the file Media3 parses and leaves them in
33
+ * [AssSignStore], and this view draws them.
31
34
  *
32
35
  * ### Where it sits
33
36
  *
@@ -36,93 +39,170 @@ import androidx.media3.ui.SubtitleView
36
39
  * survive letterboxing. `\pos` is in `PlayResX`/`PlayResY` space and maps onto this view's bounds
37
40
  * directly. [RoundedSubtitleBackground] takes the same route for the same reason.
38
41
  *
39
- * ### Why a canvas and not child views
42
+ * ### Why it drives itself from the clock
40
43
  *
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.
44
+ * Signs do not ride the cue stream — see [AssSignStore] for why they cannot — so nothing tells this
45
+ * view when one begins. It reads the player's position on each frame instead, which is also the only
46
+ * granularity that works: frame-by-frame typesetting writes one event per frame, and a sign on
47
+ * screen for 40 ms has to arrive and leave on the frame it was authored for.
46
48
  */
47
49
  @SuppressLint("ViewConstructor")
48
50
  @OptIn(UnstableApi::class)
49
51
  internal class AssSignOverlay(context: Context) : View(context), Player.Listener {
50
52
 
51
53
  private val camera = Camera()
52
- private val matrix = Matrix()
53
54
 
54
- private var signs: List<AssSign> = emptyList()
55
- private var prepared: List<PreparedSign> = emptyList()
56
55
  private var player: Player? = null
56
+ private var script: AssScript? = null
57
+
58
+ private var active: List<AssSign> = emptyList()
59
+ private var prepared: List<PreparedSign> = emptyList()
60
+
61
+ /**
62
+ * Keyed by identity, because [AssSignParser] produces one instance per event and hands back the
63
+ * same one for every frame it is on screen. Without it, a scene holding ten placards while an
64
+ * eleventh animates would re-lay-out all eleven on every 40 ms tick.
65
+ */
66
+ private var layouts = HashMap<AssSign, PreparedSign>()
67
+
68
+ private var frameScheduled = false
69
+ private val onFrame = Choreographer.FrameCallback {
70
+ frameScheduled = false
71
+ tick()
72
+ schedule()
73
+ }
74
+
75
+ // -----------------------------------------------------------------------------------------
76
+ // Binding
77
+ // -----------------------------------------------------------------------------------------
78
+
79
+ fun attachTo(player: Player?) {
80
+ if (this.player !== player) {
81
+ this.player?.removeListener(this)
82
+ this.player = player
83
+ player?.addListener(this)
84
+ }
85
+ // Re-resolved unconditionally: the same player switches between text tracks, and this doubles
86
+ // as the first resolve, before `onTracksChanged` has had anything to say.
87
+ resolveScript()
88
+ schedule()
89
+ }
90
+
91
+ fun detach() {
92
+ player?.removeListener(this)
93
+ player = null
94
+ script = null
95
+ layouts = HashMap()
96
+ setActive(emptyList())
97
+ }
98
+
99
+ override fun onTracksChanged(tracks: Tracks) {
100
+ resolveScript()
101
+ schedule()
102
+ }
103
+
104
+ private fun resolveScript() {
105
+ val trackId = selectedTextTrackId()
106
+ val next = AssSignStore.get(trackId)
107
+ if (next === script) return
108
+ // Logged because every way this has failed so far has been silent. If signs go missing again,
109
+ // this line says whether the parser filed them and whether the overlay found them.
110
+ Log.d(TAG, "text track $trackId -> ${next?.signs?.size ?: "no"} sign(s)")
111
+ script = next
112
+ layouts = HashMap()
113
+ setActive(emptyList())
114
+ }
115
+
116
+ /**
117
+ * `Format.id` of the selected text track, which `SubtitleSource.toSubtitleConfiguration` sets to
118
+ * `expo-subtitle:<language>:<uri>` — the same value the parser filed its signs under.
119
+ */
120
+ private fun selectedTextTrackId(): String? {
121
+ val groups = player?.currentTracks?.groups ?: return null
122
+ for (group in groups) {
123
+ if (group.type != C.TRACK_TYPE_TEXT) continue
124
+ for (index in 0 until group.length) {
125
+ if (group.isTrackSelected(index)) return group.getTrackFormat(index).id
126
+ }
127
+ }
128
+ return null
129
+ }
130
+
131
+ // -----------------------------------------------------------------------------------------
132
+ // The clock
133
+ // -----------------------------------------------------------------------------------------
134
+
135
+ override fun onAttachedToWindow() {
136
+ super.onAttachedToWindow()
137
+ schedule()
138
+ }
139
+
140
+ override fun onDetachedFromWindow() {
141
+ super.onDetachedFromWindow()
142
+ Choreographer.getInstance().removeFrameCallback(onFrame)
143
+ frameScheduled = false
144
+ }
57
145
 
58
- init {
59
- setWillNotDraw(false)
146
+ private fun schedule() {
147
+ // No script means nothing to draw and no reason to hold a frame callback open. Paused playback
148
+ // still ticks, because a seek while paused has to move the signs with it.
149
+ if (frameScheduled || script == null || !isAttachedToWindow) return
150
+ frameScheduled = true
151
+ Choreographer.getInstance().postFrameCallback(onFrame)
60
152
  }
61
153
 
62
- override fun onCues(cueGroup: CueGroup) {
63
- setSigns(cueGroup.cues)
154
+ private fun tick() {
155
+ val script = this.script ?: return
156
+ val player = this.player ?: return
157
+ setActive(script.signsAt(player.contentPosition * 1000L))
64
158
  }
65
159
 
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
160
+ private fun setActive(next: List<AssSign>) {
161
+ if (next.size == active.size && next.indices.all { next[it] === active[it] }) return
162
+ active = next
72
163
  rebuild()
73
164
  }
74
165
 
75
166
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
76
167
  super.onSizeChanged(w, h, oldw, oldh)
77
168
  // 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.
169
+ // happens on rotation and on entering full screen.
170
+ layouts = HashMap()
79
171
  rebuild()
80
172
  }
81
173
 
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
- */
174
+ /** Lays out only what is new on screen, and keeps the rest as it was. */
89
175
  private fun rebuild() {
90
- prepared = if (width == 0 || height == 0) emptyList() else signs.mapNotNull(::prepare)
91
- invalidate()
92
- }
176
+ if (width == 0 || height == 0) {
177
+ prepared = emptyList()
178
+ invalidate()
179
+ return
180
+ }
93
181
 
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())
182
+ val next = HashMap<AssSign, PreparedSign>(active.size)
183
+ prepared = active.mapNotNull { sign ->
184
+ (layouts[sign] ?: prepare(sign))?.also { next[sign] = it }
185
+ }
186
+ layouts = next
187
+ invalidate()
101
188
  }
102
189
 
103
- fun detach() {
104
- player?.removeListener(this)
105
- player = null
106
- signs = emptyList()
107
- rebuild()
108
- }
190
+ // -----------------------------------------------------------------------------------------
191
+ // Drawing
192
+ // -----------------------------------------------------------------------------------------
109
193
 
110
194
  override fun onDraw(canvas: Canvas) {
111
195
  for (sign in prepared) {
112
- draw(canvas, sign)
196
+ canvas.save()
197
+ canvas.concat(sign.matrix)
198
+ canvas.translate(sign.left, sign.top)
199
+ sign.stroke?.draw(canvas)
200
+ sign.fill.draw(canvas)
201
+ canvas.restore()
113
202
  }
114
203
  }
115
204
 
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. */
205
+ /** Everything about one sign that does not change while it is on screen. */
126
206
  private class PreparedSign(
127
207
  val matrix: Matrix,
128
208
  val left: Float,
@@ -141,16 +221,16 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
141
221
  if (alpha == 0) return null
142
222
 
143
223
  // 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.
224
+ // mutable one would repaint every sign already laid out.
145
225
  val fillPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
146
226
  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))
227
+ fillPaint.color = sign.colour.withAlpha(alpha)
148
228
 
149
229
  // Wrapping is the script's job, not ours: a sign is placed by hand and its line breaks are
150
230
  // written into the text as \N. A width of "as wide as it wants" reproduces that.
151
231
  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.
232
+ // A multi-line sign wraps the way its own alignment says, not always centred: a left-anchored
233
+ // placard reads wrong with its second line pulled to the middle.
154
234
  val blockAlignment = when ((sign.alignment - 1) % 3) {
155
235
  0 -> Layout.Alignment.ALIGN_NORMAL
156
236
  2 -> Layout.Alignment.ALIGN_OPPOSITE
@@ -177,23 +257,31 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
177
257
  }
178
258
 
179
259
  // \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.
260
+ // placard pivoting off-screen. Without it every rotation would spin about the text's own middle
261
+ // and drift off the artwork.
182
262
  val pivotX = sign.pivotX * scaleToViewX
183
263
  val pivotY = sign.pivotY * scaleToViewY
184
264
 
185
- matrix.reset()
265
+ // Order matters, and it is not the order the calls read in. Android's `post*` methods
266
+ // pre-multiply — `M.postRotate(r)` maps a point as `R * (M * p)` — so the first call written is
267
+ // the first one applied to the glyph. ASS scales the glyph and *then* turns it, and with an
268
+ // anisotropic scale (this episode carries fscx 125.86 against fscy 120.19) the two do not
269
+ // commute, so getting it backwards changes the shape rather than just the arithmetic.
270
+ val matrix = Matrix()
271
+ matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
186
272
  if (sign.rotationX != 0f || sign.rotationY != 0f) {
273
+ val cameraMatrix = Matrix()
187
274
  camera.save()
188
275
  camera.rotateX(sign.rotationX)
189
276
  camera.rotateY(sign.rotationY)
190
- camera.getMatrix(matrix)
277
+ // `getMatrix` overwrites rather than concatenating, so it needs a matrix of its own.
278
+ camera.getMatrix(cameraMatrix)
191
279
  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)
280
+ // The camera transforms about the origin; walk the pivot there and back.
281
+ cameraMatrix.preTranslate(-pivotX, -pivotY)
282
+ cameraMatrix.postTranslate(pivotX, pivotY)
283
+ matrix.postConcat(cameraMatrix)
195
284
  }
196
- matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
197
285
  matrix.postRotate(sign.rotationZ, pivotX, pivotY)
198
286
 
199
287
  val stroke = if (sign.outlineWidth <= 0f) null else {
@@ -201,9 +289,9 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
201
289
  strokePaint.style = Paint.Style.STROKE
202
290
  strokePaint.applySign(sign, scaleToViewY)
203
291
  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))
292
+ strokePaint.color = sign.outlineColour.withAlpha(alpha)
205
293
  // 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.
294
+ // fill over it leaves the outline the width the script asked for.
207
295
  StaticLayout.Builder
208
296
  .obtain(sign.text, 0, sign.text.length, strokePaint, textWidth)
209
297
  .setAlignment(blockAlignment)
@@ -211,7 +299,7 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
211
299
  .build()
212
300
  }
213
301
 
214
- return PreparedSign(Matrix(matrix), left, top, layout, stroke)
302
+ return PreparedSign(matrix, left, top, layout, stroke)
215
303
  }
216
304
 
217
305
  private fun TextPaint.applySign(sign: AssSign, scaleToViewY: Float) {
@@ -228,22 +316,16 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
228
316
  isUnderlineText = sign.underline
229
317
  }
230
318
 
231
- companion object {
232
- /** ASS colours are stored without an alpha byte; opacity travels separately. */
233
- private const val ALPHA_MASK = 0xFF000000.toInt()
319
+ /** ASS colours carry no alpha byte; opacity travels separately. */
320
+ private fun Int.withAlpha(alpha: Int): Int {
321
+ val opaque = this or OPAQUE
322
+ return Color.argb(alpha, Color.red(opaque), Color.green(opaque), Color.blue(opaque))
323
+ }
234
324
 
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
- }
325
+ companion object {
326
+ private const val TAG = "AssSignOverlay"
327
+ private const val OPAQUE = 0xFF000000.toInt()
239
328
 
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
329
  /** Removes the overlay from [playerView], dropping its listener registration with it. */
248
330
  fun remove(playerView: PlayerView) {
249
331
  val parent = playerView.subtitleView?.parent as? ViewGroup ?: return
@@ -255,6 +337,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
255
337
  }
256
338
  }
257
339
 
340
+ /**
341
+ * Inserts the overlay in front of `PlayerView`'s subtitle view, or returns the one already
342
+ * there.
343
+ *
344
+ * Returns null when the player view has no subtitle view — a configuration this package does not
345
+ * produce, but `PlayerView` allows.
346
+ */
258
347
  fun install(playerView: PlayerView): AssSignOverlay? {
259
348
  val subtitleView: SubtitleView = playerView.subtitleView ?: return null
260
349
  val parent = subtitleView.parent as? ViewGroup ?: return null
@@ -264,12 +353,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
264
353
  }
265
354
 
266
355
  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
- ))
356
+ // Directly in front of the captions: a sign belongs to the picture, and the dialogue box must
357
+ // not be able to cover it.
358
+ parent.addView(
359
+ overlay,
360
+ parent.indexOfChild(subtitleView) + 1,
361
+ ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
362
+ )
273
363
  return overlay
274
364
  }
275
365
  }
@@ -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,70 @@
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
+ /**
51
+ * Called from the main thread, once per track change.
52
+ *
53
+ * Falls back to the only script on hand when the id does not match. The two ends read `Format.id`
54
+ * from different places — the parser from the `Format` its factory was handed, the overlay from
55
+ * the selected track — and if those ever disagree the failure is silent and total: signs parse
56
+ * perfectly and never appear, which is exactly how the previous delivery route failed. With one
57
+ * track loaded there is no ambiguity to protect against, so the safe answer is the obvious one.
58
+ * With several, a wrong guess would draw one language's typesetting over another's, so it declines.
59
+ */
60
+ @Synchronized
61
+ fun get(trackId: String?): AssScript? {
62
+ scripts[trackId]?.let { return it }
63
+ return scripts.values.singleOrNull()
64
+ }
65
+
66
+ @Synchronized
67
+ fun clear() {
68
+ scripts.clear()
69
+ }
70
+ }
@@ -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.3",
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 = "​"