expo-video-subtitle 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,93 @@ 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.0
11
+
12
+ ### Added
13
+
14
+ - **ASS signs are drawn with the transforms the script asks for (Android).** A typeset sign is
15
+ placed on the artwork, and the artwork is usually not square to the screen: in one episode of
16
+ Jujutsu Kaisen S3, 3,723 events carry `\frz`, 1,304 of them past 5° and 104 past 45°, because the
17
+ placards they sit on are drawn at an angle. Rendered flat, the translation reads as pasted over
18
+ the picture instead of lying on it.
19
+
20
+ Media3 cannot do this and has no room to. `SubtitlePainter` contains no call to `rotate`, `skew`,
21
+ `Matrix` or `Camera`; `Cue` carries no rotation field, only `shearDegrees`, which the painter
22
+ ignores; and `SsaParser` discards every override block except `\pos`, `\move` and `\an`.
23
+
24
+ So signs leave Media3's hands entirely. `AssSignParser` splits a script in two — typeset events
25
+ to us, dialogue to the delegate, with the sign lines cut out of the copy the delegate parses, so
26
+ double-drawing is structurally impossible rather than merely avoided. Each sign rides the cue
27
+ stream as a zero-width placeholder carrying an `AssSignSpan`, which buys Media3's timing for free,
28
+ and `AssSignOverlay` draws it on a canvas in front of the captions.
29
+
30
+ Read and honoured: `\pos`, `\move` (at its destination), `\org`, `\an` and the legacy `\a`,
31
+ `\frz`, `\frx`, `\fry`, `\fscx`, `\fscy`, `\fs`, `\c` / `\1c`, `\alpha` / `\1a`, `\b`,
32
+ `\i`, `\u`, style `Alignment`, `Fontsize`, `PrimaryColour`, `OutlineColour`, `Outline`,
33
+ `ScaleX/Y`, `Angle` and the margins, plus `PlayResX` / `PlayResY` and VSFilter's 4:3 rule when one
34
+ is missing.
35
+
36
+ The overlay sits in the player's content frame, which is sized to the video rather than the view,
37
+ so positions survive letterboxing.
38
+
39
+ ### Known limitations
40
+
41
+ - `\t` animation, `\fad`, `\clip`, `\blur`, `\be`, `\fax` shear, vector drawings and karaoke are
42
+ still dropped. Reproducing those means libass, not a larger subset of it.
43
+ - Fonts fall back to the system face: a script naming `Bogart Semibold` gets the default at the
44
+ right size and weight, not that typeface.
45
+ - `\frx` and `\fry` are applied with an unverified sign convention. They are almost always tiny —
46
+ 3,363 of 5,126 `\frx` values and 2,715 of 3,579 `\fry` values in that episode are under 3° — so a
47
+ wrong sign is subtle rather than visible. `\frz` is what carries the effect and is verified
48
+ against the source line it came from.
49
+ - iOS still draws signs flat. AVFoundation renders WebVTT itself and has no rotation to give.
50
+
51
+ ### Changed
52
+
53
+ - The module now has JVM unit tests (`./gradlew :expo-video-subtitle:testDebugUnitTest`). The sign
54
+ parser is pure Kotlin with no Android imports precisely so it can be tested this way: its tag
55
+ scanner and colour handling fail by drawing a sign in the wrong place, which nothing else catches.
56
+
57
+ ## 0.6.0
58
+
59
+ ### Fixed
60
+
61
+ - **ASS dialogue keeps working with `bottomOffset` and with cue stacking again (Android).** Media3's
62
+ `SsaParser` calls `setLine` and `setPosition` on *every* cue, including the bottom-centre dialogue
63
+ the author placed nothing on. `SubtitlePainter` applies `bottomPaddingFraction` only to cues whose
64
+ `line` is unset, so `SubtitleSource.bottomOffset` silently stopped moving anything on an `.ass`
65
+ track, and `StackingSubtitleParser` treated every cue as deliberately placed and left simultaneous
66
+ speakers drawing on top of each other.
67
+
68
+ A new `SsaCueNormalizer` clears the placement Media3 filled in. Recognising it is exact rather
69
+ than a guess: the values are a pure function of the cue's anchors, so a cue matching both defaults
70
+ for the bottom-centre anchors is precisely a cue with no positioning tag. Anything the author did
71
+ place — `\an8`, `\pos`, a style aligned elsewhere — keeps everything and still bypasses stacking.
72
+
73
+ - **Signs keep the colours they were typeset in (Android).** The span filter dropped colour from
74
+ every cue so the app's own subtitle style would win, which is right for dialogue and wrong for a
75
+ sign: typesetting is drawn onto the artwork in colours chosen to sit on it, and the dark lettering
76
+ on the white placards in Jujutsu Kaisen S3E3 became white-on-white. Cues an ASS track positions
77
+ itself now keep their appearance; unpositioned dialogue still loses its own and follows the
78
+ viewer's settings. Only ASS opts in — it is the one format where positioning reliably means
79
+ "sign" rather than a cue setting on ordinary dialogue.
80
+
81
+ - **Typesetters' working notes stay hidden.** A script ships its production notes on a style that
82
+ renders nothing: JJK S3E3 carries `Style: f0ld3r-invisible` with `Fontsize 0` and every colour
83
+ `&HFF……`, holding 158 events of "--mahito", "#1", "check in frame 6212". libass honours both
84
+ signals. Media3 turns a transparent primary colour into a `ForegroundColorSpan` with alpha 0,
85
+ which the span filter then strips, so the note came back in the viewer's colour at the viewer's
86
+ size; the iOS converter never read either field, and WebVTT cannot express opacity anyway. Events
87
+ on such a style are now dropped on both platforms, before anything can make them visible.
88
+
89
+ ### Known limitations
90
+
91
+ - Sign **font size** is still the app's, not the script's. Media3 does read `Fontsize` into
92
+ `Cue.textSize`, but the filter clears it so the two subtitle views measure the same glyphs; giving
93
+ it back to signs needs the rounded-background view checked on a device first.
94
+ - Blur, borders, shadows, rotation and `\t` animation are still dropped. Matching libass output
95
+ exactly needs libass, not Media3.
96
+
10
97
  ## 0.5.0
11
98
 
12
99
  ### Added
package/FORK.md CHANGED
@@ -27,7 +27,11 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
27
27
  | `android/…/utils/SubtitleSpanFilter.kt` | 135 | Strips colour, size, family and weight from every cue of every format, keeping italic/underline/strikethrough and all positioning, plus `FilteringSubtitleParser` which applies it to whatever a delegate emits. **Load-bearing fact, verified against Media3 1.9.0 bytecode:** `SubtitleViewUtils.removeAllEmbeddedStyling` touches only `clearWindowColor`, `getText`/`setText` and `setTextSize` — never `line`, `position` or `textAlignment`. That is why the filter can live in the parser and the view flag can stay on; if an upstream sync changes it, this whole design has to be revisited. Uses an **allowlist**, because a denylist misses `TextAppearanceSpan` (colour + size + family in one span). `StyleSpan` needs a rewrite rather than a keep/drop: `BOLD_ITALIC` has to come back as plain `ITALIC` over the same range. |
28
28
  | `android/…/records/SubtitleSource.kt` | 63 | Record → `MediaItem.SubtitleConfiguration` + MIME resolution. |
29
29
  | `android/…/records/SubtitleStyle.kt` | 152 | Record → `CaptionStyleCompat`; hex colour parsing; font-family resolution (`res/font` → `ReactFontManager`); the `applyEmbeddedStyles` flag, which `SubtitleUtils` reads directly rather than `toCaptionStyle` and which defaults to `true` since 0.5.0. |
30
- | `android/…/utils/StackingSubtitleParser.kt` | 308 | `SubtitleParser` for SubRip that segments the cue timeline and merges simultaneous cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip to it and wraps every parser in `FilteringSubtitleParser`. SSA/ASS is deliberately not stacked: `SsaParser` sets both `line` and `position` on every cue, so they would all take the passthrough branch anyway. 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. |
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
+ | `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
+ | `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. |
31
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. |
32
36
  | `FORK.md`, `LICENSE` | — | This guide; MIT licence (upstream copyright preserved). |
33
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". |
@@ -26,6 +26,11 @@ dependencies {
26
26
  implementation "androidx.media3:media3-ui:${androidxMedia3Version}"
27
27
  implementation "androidx.media3:media3-datasource-okhttp:${androidxMedia3Version}"
28
28
 
29
+ // The ASS sign parser is pure Kotlin with no Android imports, which is what makes it worth
30
+ // testing on the JVM: its tag scanner and colour handling are the parts that fail silently on a
31
+ // device, by drawing a sign in the wrong place rather than by crashing.
32
+ testImplementation 'junit:junit:4.13.2'
33
+
29
34
  def fragment_version = "1.8.9"
30
35
  implementation "androidx.fragment:fragment:$fragment_version"
31
36
  implementation "androidx.fragment:fragment-ktx:$fragment_version"
@@ -0,0 +1,269 @@
1
+ package expo.modules.video.utils
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.graphics.Camera
6
+ import android.graphics.Canvas
7
+ import android.graphics.Color
8
+ import android.graphics.Matrix
9
+ import android.graphics.Paint
10
+ import android.graphics.Typeface
11
+ import android.text.Layout
12
+ import android.text.StaticLayout
13
+ import android.text.TextPaint
14
+ import android.view.View
15
+ import android.view.ViewGroup
16
+ import androidx.annotation.OptIn
17
+ import androidx.media3.common.Player
18
+ import androidx.media3.common.text.Cue
19
+ import androidx.media3.common.text.CueGroup
20
+ import androidx.media3.common.util.UnstableApi
21
+ import androidx.media3.ui.PlayerView
22
+ import androidx.media3.ui.SubtitleView
23
+
24
+ /**
25
+ * Draws ASS typesetting — signs, placards, on-screen labels — with the transforms the script asks
26
+ * for.
27
+ *
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.
31
+ *
32
+ * ### Where it sits
33
+ *
34
+ * As a sibling of `PlayerView`'s own `SubtitleView`, in front of it. That parent is the content
35
+ * frame, which is sized to the *video*, not to the player — which is the whole reason positions
36
+ * survive letterboxing. `\pos` is in `PlayResX`/`PlayResY` space and maps onto this view's bounds
37
+ * directly. [RoundedSubtitleBackground] takes the same route for the same reason.
38
+ *
39
+ * ### Why a canvas and not child views
40
+ *
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.
46
+ */
47
+ @SuppressLint("ViewConstructor")
48
+ @OptIn(UnstableApi::class)
49
+ internal class AssSignOverlay(context: Context) : View(context), Player.Listener {
50
+
51
+ private val camera = Camera()
52
+ private val matrix = Matrix()
53
+
54
+ private var signs: List<AssSign> = emptyList()
55
+ private var prepared: List<PreparedSign> = emptyList()
56
+ private var player: Player? = null
57
+
58
+ init {
59
+ setWillNotDraw(false)
60
+ }
61
+
62
+ override fun onCues(cueGroup: CueGroup) {
63
+ setSigns(cueGroup.cues)
64
+ }
65
+
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
72
+ rebuild()
73
+ }
74
+
75
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
76
+ super.onSizeChanged(w, h, oldw, oldh)
77
+ // 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.
79
+ rebuild()
80
+ }
81
+
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
+ */
89
+ private fun rebuild() {
90
+ prepared = if (width == 0 || height == 0) emptyList() else signs.mapNotNull(::prepare)
91
+ invalidate()
92
+ }
93
+
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())
101
+ }
102
+
103
+ fun detach() {
104
+ player?.removeListener(this)
105
+ player = null
106
+ signs = emptyList()
107
+ rebuild()
108
+ }
109
+
110
+ override fun onDraw(canvas: Canvas) {
111
+ for (sign in prepared) {
112
+ draw(canvas, sign)
113
+ }
114
+ }
115
+
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. */
126
+ private class PreparedSign(
127
+ val matrix: Matrix,
128
+ val left: Float,
129
+ val top: Float,
130
+ val fill: StaticLayout,
131
+ val stroke: StaticLayout?,
132
+ )
133
+
134
+ private fun prepare(sign: AssSign): PreparedSign? {
135
+ // Script space to view space. Both axes are scaled independently so a script authored at a
136
+ // different aspect ratio than the video still lands where it was drawn.
137
+ val scaleToViewX = width / sign.playResX
138
+ val scaleToViewY = height / sign.playResY
139
+
140
+ val alpha = (sign.alpha * 255f).toInt().coerceIn(0, 255)
141
+ if (alpha == 0) return null
142
+
143
+ // 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.
145
+ val fillPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
146
+ 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))
148
+
149
+ // Wrapping is the script's job, not ours: a sign is placed by hand and its line breaks are
150
+ // written into the text as \N. A width of "as wide as it wants" reproduces that.
151
+ val textWidth = Layout.getDesiredWidth(sign.text, fillPaint).toInt() + 1
152
+ val layout = StaticLayout.Builder
153
+ .obtain(sign.text, 0, sign.text.length, fillPaint, textWidth)
154
+ .setAlignment(Layout.Alignment.ALIGN_CENTER)
155
+ .setIncludePad(false)
156
+ .build()
157
+
158
+ val anchorX = sign.x * scaleToViewX
159
+ val anchorY = sign.y * scaleToViewY
160
+ // \pos names the point the cue's *alignment corner* sits on, so the box is offset from it.
161
+ val left = anchorX - when ((sign.alignment - 1) % 3) {
162
+ 0 -> 0f
163
+ 2 -> layout.width.toFloat()
164
+ else -> layout.width / 2f
165
+ }
166
+ val top = anchorY - when ((sign.alignment - 1) / 3) {
167
+ 0 -> layout.height.toFloat()
168
+ 2 -> 0f
169
+ else -> layout.height / 2f
170
+ }
171
+
172
+ // \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.
175
+ val pivotX = sign.pivotX * scaleToViewX
176
+ val pivotY = sign.pivotY * scaleToViewY
177
+
178
+ matrix.reset()
179
+ if (sign.rotationX != 0f || sign.rotationY != 0f) {
180
+ camera.save()
181
+ camera.rotateX(sign.rotationX)
182
+ camera.rotateY(sign.rotationY)
183
+ camera.getMatrix(matrix)
184
+ 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)
188
+ }
189
+ matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
190
+ matrix.postRotate(sign.rotationZ, pivotX, pivotY)
191
+
192
+ val stroke = if (sign.outlineWidth <= 0f) null else {
193
+ val strokePaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
194
+ strokePaint.style = Paint.Style.STROKE
195
+ strokePaint.applySign(sign, scaleToViewY)
196
+ 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))
198
+ // 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.
200
+ StaticLayout.Builder
201
+ .obtain(sign.text, 0, sign.text.length, strokePaint, textWidth)
202
+ .setAlignment(Layout.Alignment.ALIGN_CENTER)
203
+ .setIncludePad(false)
204
+ .build()
205
+ }
206
+
207
+ return PreparedSign(Matrix(matrix), left, top, layout, stroke)
208
+ }
209
+
210
+ private fun TextPaint.applySign(sign: AssSign, scaleToViewY: Float) {
211
+ textSize = sign.fontSize * scaleToViewY
212
+ typeface = Typeface.create(
213
+ Typeface.DEFAULT,
214
+ when {
215
+ sign.bold && sign.italic -> Typeface.BOLD_ITALIC
216
+ sign.bold -> Typeface.BOLD
217
+ sign.italic -> Typeface.ITALIC
218
+ else -> Typeface.NORMAL
219
+ },
220
+ )
221
+ isUnderlineText = sign.underline
222
+ }
223
+
224
+ companion object {
225
+ /** ASS colours are stored without an alpha byte; opacity travels separately. */
226
+ private const val ALPHA_MASK = 0xFF000000.toInt()
227
+
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
+ }
232
+
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
+ /** Removes the overlay from [playerView], dropping its listener registration with it. */
241
+ fun remove(playerView: PlayerView) {
242
+ val parent = playerView.subtitleView?.parent as? ViewGroup ?: return
243
+ for (index in 0 until parent.childCount) {
244
+ val overlay = parent.getChildAt(index) as? AssSignOverlay ?: continue
245
+ overlay.detach()
246
+ parent.removeView(overlay)
247
+ return
248
+ }
249
+ }
250
+
251
+ fun install(playerView: PlayerView): AssSignOverlay? {
252
+ val subtitleView: SubtitleView = playerView.subtitleView ?: return null
253
+ val parent = subtitleView.parent as? ViewGroup ?: return null
254
+
255
+ for (index in 0 until parent.childCount) {
256
+ (parent.getChildAt(index) as? AssSignOverlay)?.let { return it }
257
+ }
258
+
259
+ 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
+ ))
266
+ return overlay
267
+ }
268
+ }
269
+ }