expo-video-subtitle 0.7.2 → 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,50 @@ 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
+
35
+ ## 0.7.3
36
+
37
+ Hardening only. Nothing here changes what 0.7.2 draws when 0.7.2 works.
38
+
39
+ ### Changed
40
+
41
+ - **The sign store falls back to the only script it holds when the track id does not match.** The
42
+ parser reads `Format.id` from the `Format` its factory was handed; the overlay reads it from the
43
+ selected text track. Those should be the same value, but if they ever are not, the failure is
44
+ silent and total — signs parse correctly and never appear, which is precisely how the previous two
45
+ delivery attempts failed. With one track loaded there is nothing to be ambiguous about. With
46
+ several it still declines, because a wrong guess would draw one language's typesetting over
47
+ another's.
48
+
49
+ - **The overlay logs what it resolved.** Every way this subsystem has failed so far has been quiet:
50
+ a dropped span, a rejected timestamp, an id that did not match. One line under the `AssSignOverlay`
51
+ tag now says which text track was selected and how many signs were found for it, so the next
52
+ failure is one `logcat` away instead of a bisect.
53
+
10
54
  ## 0.7.2
11
55
 
12
56
  ### Fixed
@@ -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
@@ -18,6 +19,7 @@ import androidx.annotation.OptIn
18
19
  import androidx.media3.common.C
19
20
  import androidx.media3.common.Player
20
21
  import androidx.media3.common.Tracks
22
+ import androidx.media3.common.util.Log
21
23
  import androidx.media3.common.util.UnstableApi
22
24
  import androidx.media3.ui.PlayerView
23
25
  import androidx.media3.ui.SubtitleView
@@ -101,8 +103,13 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
101
103
  }
102
104
 
103
105
  private fun resolveScript() {
104
- val next = AssSignStore.get(selectedTextTrackId())
106
+ val trackId = selectedTextTrackId()
107
+ val next = AssSignStore.get(trackId)
105
108
  if (next === script) return
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"}")
106
113
  script = next
107
114
  layouts = HashMap()
108
115
  setActive(emptyList())
@@ -138,20 +145,48 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
138
145
  frameScheduled = false
139
146
  }
140
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
+ */
141
159
  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
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
145
162
  frameScheduled = true
146
163
  Choreographer.getInstance().postFrameCallback(onFrame)
147
164
  }
148
165
 
166
+
167
+ private var lastLookupMs = 0L
168
+
149
169
  private fun tick() {
150
- val script = this.script ?: return
151
170
  val player = this.player ?: return
152
- 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))
153
187
  }
154
188
 
189
+
155
190
  private fun setActive(next: List<AssSign>) {
156
191
  if (next.size == active.size && next.indices.all { next[it] === active[it] }) return
157
192
  active = next
@@ -318,6 +353,10 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
318
353
  }
319
354
 
320
355
  companion object {
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
321
360
  private const val OPAQUE = 0xFF000000.toInt()
322
361
 
323
362
  /** Removes the overlay from [playerView], dropping its listener registration with it. */
@@ -339,8 +378,10 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
339
378
  * produce, but `PlayerView` allows.
340
379
  */
341
380
  fun install(playerView: PlayerView): AssSignOverlay? {
342
- val subtitleView: SubtitleView = playerView.subtitleView ?: return null
343
- 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
344
385
 
345
386
  for (index in 0 until parent.childCount) {
346
387
  (parent.getChildAt(index) as? AssSignOverlay)?.let { return it }
@@ -47,9 +47,21 @@ internal object AssSignStore {
47
47
  scripts[trackId] = script
48
48
  }
49
49
 
50
- /** Called from the main thread, once per track change. */
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
+ */
51
60
  @Synchronized
52
- fun get(trackId: String?): AssScript? = trackId?.let { scripts[it] }
61
+ fun get(trackId: String?): AssScript? {
62
+ scripts[trackId]?.let { return it }
63
+ return scripts.values.singleOrNull()
64
+ }
53
65
 
54
66
  @Synchronized
55
67
  fun clear() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-video-subtitle",
3
- "version": "0.7.2",
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",