expo-video-subtitle 0.7.3 → 0.7.5

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,59 @@ 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.5
11
+
12
+ ### Fixed
13
+
14
+ - **One episode's signs drew over the next one.** Opening episode 4 after episode 3 kept episode 3's
15
+ typesetting on screen for the whole runtime.
16
+
17
+ The cause was insurance added in 0.7.3 that turned into the bug it was insuring against.
18
+ `AssSignStore.get` answered a missed track id with "the only script on hand", to protect against
19
+ the parser and the overlay disagreeing about `Format.id`. They do not disagree — a device run
20
+ confirmed both name the same track — and the fallback fired in a case that was never considered:
21
+ a new episode misses on its own id for the first twelve seconds, while its 2.7 MB file is still
22
+ parsing, and the only script on hand is the previous episode's. Once resolved, the overlay stops
23
+ looking for a better one, so it never corrected itself.
24
+
25
+ A miss is now a miss. It means "not parsed yet", which the overlay already polls through.
26
+
27
+ - **A track change is noticed without waiting to be told.** The overlay re-resolved only when it had
28
+ no script at all, so it leaned on `onTracksChanged` to catch a switch. It now compares the track
29
+ its script came from against the one currently selected, four times a second, and re-resolves when
30
+ they differ.
31
+
32
+ ### Changed
33
+
34
+ - The store keeps two tracks rather than four. That covers switching language within an episode and
35
+ moving to the next one, and each entry holds every sign in an episode — 12,616 in the file this
36
+ was built against.
37
+
38
+ ## 0.7.4
39
+
40
+ Signs render. This is the release where the feature added in 0.7.0 actually reaches the screen.
41
+
42
+ ### Fixed
43
+
44
+ - **The overlay gave up looking for its signs before they existed.** The subtitle is fetched and
45
+ parsed on the extractor's loading thread; on a 2.7 MB script that finished **12.4 seconds** after
46
+ the view had bound to the player. The overlay asked the store once at bind time, got nothing, and
47
+ never asked again — because the only thing that would have asked again was the frame loop, and the
48
+ loop was gated on already having a script. Nothing ever broke that circle.
49
+
50
+ The loop now runs whenever there is a player, and polls the store four times a second until a
51
+ script arrives.
52
+
53
+ - **The loop declined to start at the moment it was first asked.** `attachTo` runs from
54
+ `configureSubtitleView`, which fires while the view is still being assembled: `player=true`,
55
+ `attached=false`. `onAttachedToWindow` is what starts it, and it now does.
56
+
57
+ ### Known behaviour
58
+
59
+ - Signs appear a few seconds into playback on a large script, after the file has downloaded and
60
+ parsed. Dialogue is unaffected — it comes from the same file but is rendered by Media3 as it
61
+ arrives.
62
+
10
63
  ## 0.7.3
11
64
 
12
65
  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
@@ -54,6 +55,8 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
54
55
 
55
56
  private var player: Player? = null
56
57
  private var script: AssScript? = null
58
+ /** The track [script] belongs to, so a change of track is noticed even mid-poll. */
59
+ private var scriptTrackId: String? = null
57
60
 
58
61
  private var active: List<AssSign> = emptyList()
59
62
  private var prepared: List<PreparedSign> = emptyList()
@@ -92,6 +95,7 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
92
95
  player?.removeListener(this)
93
96
  player = null
94
97
  script = null
98
+ scriptTrackId = null
95
99
  layouts = HashMap()
96
100
  setActive(emptyList())
97
101
  }
@@ -104,10 +108,12 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
104
108
  private fun resolveScript() {
105
109
  val trackId = selectedTextTrackId()
106
110
  val next = AssSignStore.get(trackId)
111
+ scriptTrackId = trackId
107
112
  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)")
113
+ // The one line worth keeping. Every failure of this subsystem has been silent — a span dropped
114
+ // by Parcel, a timestamp rejected, a loop that never started — and each time this line would
115
+ // have named the stage that broke.
116
+ Log.i(TAG, "ASS signs for $trackId: ${next?.signs?.size ?: "none"}")
111
117
  script = next
112
118
  layouts = HashMap()
113
119
  setActive(emptyList())
@@ -143,20 +149,54 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
143
149
  frameScheduled = false
144
150
  }
145
151
 
152
+ /**
153
+ * The loop runs whenever there is a player, script or not.
154
+ *
155
+ * Gating it on having a script deadlocked the whole feature. The subtitle is fetched and parsed on
156
+ * the extractor's loading thread and lands seconds after the view has bound — measured at 12.4s on
157
+ * a 2.7 MB script, against a bind at 0.04s. The overlay looked in the store once, found it empty,
158
+ * and then never looked again, because the only thing that would have looked again was the loop it
159
+ * had just declined to start.
160
+ *
161
+ * Paused playback ticks too: a seek while paused has to move the signs with it.
162
+ */
146
163
  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
164
+ // Not logged: this reposts on every frame by design, and a line here would be sixty a second.
165
+ if (frameScheduled || player == null || !isAttachedToWindow) return
150
166
  frameScheduled = true
151
167
  Choreographer.getInstance().postFrameCallback(onFrame)
152
168
  }
153
169
 
170
+
171
+ private var lastLookupMs = 0L
172
+
154
173
  private fun tick() {
155
- val script = this.script ?: return
156
174
  val player = this.player ?: return
157
- setActive(script.signsAt(player.contentPosition * 1000L))
175
+
176
+ // Polled rather than waited for, on two counts.
177
+ //
178
+ // A script arrives late: the parser runs on another thread whenever the file finishes
179
+ // downloading — about twelve seconds on a 2.7 MB script — and nothing in Media3 announces "a
180
+ // subtitle has been parsed". The cue stream would have, which is exactly what this design gave
181
+ // up.
182
+ //
183
+ // And the track changes underneath: moving to the next episode swaps the selected text track,
184
+ // and `onTracksChanged` is not something to stake the whole feature on. Comparing the track this
185
+ // script came from against the one selected now catches it either way — without that, episode
186
+ // three's signs kept drawing over episode four.
187
+ val now = SystemClock.uptimeMillis()
188
+ if (now - lastLookupMs >= LOOKUP_INTERVAL_MS) {
189
+ lastLookupMs = now
190
+ if (script == null || selectedTextTrackId() != scriptTrackId) resolveScript()
191
+ }
192
+ if (script == null) return
193
+
194
+ val script = this.script ?: return
195
+ val positionUs = player.contentPosition * 1000L
196
+ setActive(script.signsAt(positionUs))
158
197
  }
159
198
 
199
+
160
200
  private fun setActive(next: List<AssSign>) {
161
201
  if (next.size == active.size && next.indices.all { next[it] === active[it] }) return
162
202
  active = next
@@ -324,6 +364,9 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
324
364
 
325
365
  companion object {
326
366
  private const val TAG = "AssSignOverlay"
367
+
368
+ /** How often to ask the store for a script that has not arrived yet. */
369
+ private const val LOOKUP_INTERVAL_MS = 250L
327
370
  private const val OPAQUE = 0xFF000000.toInt()
328
371
 
329
372
  /** Removes the overlay from [playerView], dropping its listener registration with it. */
@@ -345,8 +388,10 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
345
388
  * produce, but `PlayerView` allows.
346
389
  */
347
390
  fun install(playerView: PlayerView): AssSignOverlay? {
348
- val subtitleView: SubtitleView = playerView.subtitleView ?: return null
349
- val parent = subtitleView.parent as? ViewGroup ?: return null
391
+ val subtitleView: SubtitleView = playerView.subtitleView
392
+ ?: return null
393
+ val parent = subtitleView.parent as? ViewGroup
394
+ ?: return null
350
395
 
351
396
  for (index in 0 until parent.childCount) {
352
397
  (parent.getChildAt(index) as? AssSignOverlay)?.let { return it }
@@ -30,11 +30,11 @@ package expo.modules.video.utils
30
30
  internal object AssSignStore {
31
31
 
32
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.
33
+ * The track being watched plus the one before it, which covers switching language mid-episode and
34
+ * moving to the next episode. Each entry holds every sign in an episode — 12,616 in the file this
35
+ * was built against — so this is not a cache to let grow.
36
36
  */
37
- private const val MAX_TRACKS = 4
37
+ private const val MAX_TRACKS = 2
38
38
 
39
39
  private val scripts = object : LinkedHashMap<String, AssScript>(MAX_TRACKS, 0.75f, true) {
40
40
  override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, AssScript>): Boolean =
@@ -48,20 +48,20 @@ internal object AssSignStore {
48
48
  }
49
49
 
50
50
  /**
51
- * Called from the main thread, once per track change.
51
+ * Strictly by id. A miss is a miss.
52
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.
53
+ * This used to fall back to the only script on hand, as insurance against the two ends reading
54
+ * `Format.id` from different places and disagreeing. They do not disagree — the device confirmed
55
+ * the parser and the overlay name the same track — and the insurance turned out to be a bug of its
56
+ * own: opening a second episode misses on the new id, and "the only script on hand" is the
57
+ * *previous* episode's. Its signs then draw over the new one for its whole runtime, because a
58
+ * resolved script stops the overlay from looking again.
59
+ *
60
+ * A miss here is not a failure. It means the file has not finished parsing yet, which on a 2.7 MB
61
+ * script takes about twelve seconds, and the overlay polls until it has.
59
62
  */
60
63
  @Synchronized
61
- fun get(trackId: String?): AssScript? {
62
- scripts[trackId]?.let { return it }
63
- return scripts.values.singleOrNull()
64
- }
64
+ fun get(trackId: String?): AssScript? = trackId?.let { scripts[it] }
65
65
 
66
66
  @Synchronized
67
67
  fun clear() {
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.5",
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",