expo-video-subtitle 0.7.3 → 0.7.4

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,31 @@ 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.4
11
+
12
+ Signs render. This is the release where the feature added in 0.7.0 actually reaches the screen.
13
+
14
+ ### Fixed
15
+
16
+ - **The overlay gave up looking for its signs before they existed.** The subtitle is fetched and
17
+ parsed on the extractor's loading thread; on a 2.7 MB script that finished **12.4 seconds** after
18
+ the view had bound to the player. The overlay asked the store once at bind time, got nothing, and
19
+ never asked again — because the only thing that would have asked again was the frame loop, and the
20
+ loop was gated on already having a script. Nothing ever broke that circle.
21
+
22
+ The loop now runs whenever there is a player, and polls the store four times a second until a
23
+ script arrives.
24
+
25
+ - **The loop declined to start at the moment it was first asked.** `attachTo` runs from
26
+ `configureSubtitleView`, which fires while the view is still being assembled: `player=true`,
27
+ `attached=false`. `onAttachedToWindow` is what starts it, and it now does.
28
+
29
+ ### Known behaviour
30
+
31
+ - Signs appear a few seconds into playback on a large script, after the file has downloaded and
32
+ parsed. Dialogue is unaffected — it comes from the same file but is rendered by Media3 as it
33
+ arrives.
34
+
10
35
  ## 0.7.3
11
36
 
12
37
  Hardening only. Nothing here changes what 0.7.2 draws when 0.7.2 works.
@@ -8,6 +8,7 @@ import android.graphics.Color
8
8
  import android.graphics.Matrix
9
9
  import android.graphics.Paint
10
10
  import android.graphics.Typeface
11
+ import android.os.SystemClock
11
12
  import android.text.Layout
12
13
  import android.text.StaticLayout
13
14
  import android.text.TextPaint
@@ -105,9 +106,10 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
105
106
  val trackId = selectedTextTrackId()
106
107
  val next = AssSignStore.get(trackId)
107
108
  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)")
109
+ // The one line worth keeping. Every failure of this subsystem has been silent — a span dropped
110
+ // by Parcel, a timestamp rejected, a loop that never started — and each time this line would
111
+ // have named the stage that broke.
112
+ Log.i(TAG, "ASS signs for $trackId: ${next?.signs?.size ?: "none"}")
111
113
  script = next
112
114
  layouts = HashMap()
113
115
  setActive(emptyList())
@@ -143,20 +145,48 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
143
145
  frameScheduled = false
144
146
  }
145
147
 
148
+ /**
149
+ * The loop runs whenever there is a player, script or not.
150
+ *
151
+ * Gating it on having a script deadlocked the whole feature. The subtitle is fetched and parsed on
152
+ * the extractor's loading thread and lands seconds after the view has bound — measured at 12.4s on
153
+ * a 2.7 MB script, against a bind at 0.04s. The overlay looked in the store once, found it empty,
154
+ * and then never looked again, because the only thing that would have looked again was the loop it
155
+ * had just declined to start.
156
+ *
157
+ * Paused playback ticks too: a seek while paused has to move the signs with it.
158
+ */
146
159
  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
160
+ // Not logged: this reposts on every frame by design, and a line here would be sixty a second.
161
+ if (frameScheduled || player == null || !isAttachedToWindow) return
150
162
  frameScheduled = true
151
163
  Choreographer.getInstance().postFrameCallback(onFrame)
152
164
  }
153
165
 
166
+
167
+ private var lastLookupMs = 0L
168
+
154
169
  private fun tick() {
155
- val script = this.script ?: return
156
170
  val player = this.player ?: return
157
- setActive(script.signsAt(player.contentPosition * 1000L))
171
+
172
+ if (script == null) {
173
+ // Poll rather than wait to be told. The parser finishes on another thread whenever the file
174
+ // finishes downloading, and nothing in Media3 announces "a subtitle has been parsed" — the
175
+ // cue stream would have, which is exactly what this design gave up. Four times a second is
176
+ // far below the cost of the map lookup it does.
177
+ val now = SystemClock.uptimeMillis()
178
+ if (now - lastLookupMs < LOOKUP_INTERVAL_MS) return
179
+ lastLookupMs = now
180
+ resolveScript()
181
+ if (script == null) return
182
+ }
183
+
184
+ val script = this.script ?: return
185
+ val positionUs = player.contentPosition * 1000L
186
+ setActive(script.signsAt(positionUs))
158
187
  }
159
188
 
189
+
160
190
  private fun setActive(next: List<AssSign>) {
161
191
  if (next.size == active.size && next.indices.all { next[it] === active[it] }) return
162
192
  active = next
@@ -324,6 +354,9 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
324
354
 
325
355
  companion object {
326
356
  private const val TAG = "AssSignOverlay"
357
+
358
+ /** How often to ask the store for a script that has not arrived yet. */
359
+ private const val LOOKUP_INTERVAL_MS = 250L
327
360
  private const val OPAQUE = 0xFF000000.toInt()
328
361
 
329
362
  /** Removes the overlay from [playerView], dropping its listener registration with it. */
@@ -345,8 +378,10 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
345
378
  * produce, but `PlayerView` allows.
346
379
  */
347
380
  fun install(playerView: PlayerView): AssSignOverlay? {
348
- val subtitleView: SubtitleView = playerView.subtitleView ?: return null
349
- val parent = subtitleView.parent as? ViewGroup ?: return null
381
+ val subtitleView: SubtitleView = playerView.subtitleView
382
+ ?: return null
383
+ val parent = subtitleView.parent as? ViewGroup
384
+ ?: return null
350
385
 
351
386
  for (index in 0 until parent.childCount) {
352
387
  (parent.getChildAt(index) as? AssSignOverlay)?.let { return it }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-video-subtitle",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
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",