expo-video-subtitle 0.7.4 → 0.7.6
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 +55 -0
- package/android/src/main/java/expo/modules/video/utils/AssSignOverlay.kt +35 -10
- package/android/src/main/java/expo/modules/video/utils/AssSignStore.kt +37 -16
- package/android/src/main/java/expo/modules/video/utils/StackingSubtitleParser.kt +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ 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.6
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Signs stopped appearing at all.** The id lookup between the parser and the overlay had never
|
|
15
|
+
matched. Media3 prefixes a merged track's id with the index of the source it came from, so the
|
|
16
|
+
parser is handed `expo-subtitle:id:https://…` while the overlay reads
|
|
17
|
+
`2:expo-subtitle:id:https://…` off the selected track. Every lookup missed, and the
|
|
18
|
+
"use the only script on hand" fallback quietly covered for it — which is why removing that
|
|
19
|
+
fallback in 0.7.5 took the whole feature with it, and why the fallback's own bug (one episode's
|
|
20
|
+
signs drawing over the next) had been the only visible symptom until then.
|
|
21
|
+
|
|
22
|
+
Both ends now normalize the id through the same function before touching the map. Five tests pin
|
|
23
|
+
it, using the exact strings a device produced, and were checked by removing the normalization and
|
|
24
|
+
watching them fail.
|
|
25
|
+
|
|
26
|
+
- **A track with no typesetting no longer logs once a second forever.** The parser files its result
|
|
27
|
+
even when there are no signs in it. Absence in the store used to mean two different things —
|
|
28
|
+
"still parsing" and "nothing to draw" — and the overlay kept asking for the second one.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- A missed lookup is now logged, throttled to once a second, naming both the id being asked for and
|
|
33
|
+
the ids on hand. Its absence is what let this bug hide behind a fallback for three releases: the
|
|
34
|
+
overlay asked, got nothing, and said nothing, so a wrong key looked exactly like a file that had
|
|
35
|
+
not finished parsing.
|
|
36
|
+
|
|
37
|
+
## 0.7.5
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **One episode's signs drew over the next one.** Opening episode 4 after episode 3 kept episode 3's
|
|
42
|
+
typesetting on screen for the whole runtime.
|
|
43
|
+
|
|
44
|
+
The cause was insurance added in 0.7.3 that turned into the bug it was insuring against.
|
|
45
|
+
`AssSignStore.get` answered a missed track id with "the only script on hand", to protect against
|
|
46
|
+
the parser and the overlay disagreeing about `Format.id`. They do not disagree — a device run
|
|
47
|
+
confirmed both name the same track — and the fallback fired in a case that was never considered:
|
|
48
|
+
a new episode misses on its own id for the first twelve seconds, while its 2.7 MB file is still
|
|
49
|
+
parsing, and the only script on hand is the previous episode's. Once resolved, the overlay stops
|
|
50
|
+
looking for a better one, so it never corrected itself.
|
|
51
|
+
|
|
52
|
+
A miss is now a miss. It means "not parsed yet", which the overlay already polls through.
|
|
53
|
+
|
|
54
|
+
- **A track change is noticed without waiting to be told.** The overlay re-resolved only when it had
|
|
55
|
+
no script at all, so it leaned on `onTracksChanged` to catch a switch. It now compares the track
|
|
56
|
+
its script came from against the one currently selected, four times a second, and re-resolves when
|
|
57
|
+
they differ.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- The store keeps two tracks rather than four. That covers switching language within an episode and
|
|
62
|
+
moving to the next one, and each entry holds every sign in an episode — 12,616 in the file this
|
|
63
|
+
was built against.
|
|
64
|
+
|
|
10
65
|
## 0.7.4
|
|
11
66
|
|
|
12
67
|
Signs render. This is the release where the feature added in 0.7.0 actually reaches the screen.
|
|
@@ -55,6 +55,8 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
55
55
|
|
|
56
56
|
private var player: Player? = null
|
|
57
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
|
|
58
60
|
|
|
59
61
|
private var active: List<AssSign> = emptyList()
|
|
60
62
|
private var prepared: List<PreparedSign> = emptyList()
|
|
@@ -93,6 +95,7 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
93
95
|
player?.removeListener(this)
|
|
94
96
|
player = null
|
|
95
97
|
script = null
|
|
98
|
+
scriptTrackId = null
|
|
96
99
|
layouts = HashMap()
|
|
97
100
|
setActive(emptyList())
|
|
98
101
|
}
|
|
@@ -105,7 +108,19 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
105
108
|
private fun resolveScript() {
|
|
106
109
|
val trackId = selectedTextTrackId()
|
|
107
110
|
val next = AssSignStore.get(trackId)
|
|
108
|
-
|
|
111
|
+
scriptTrackId = trackId
|
|
112
|
+
if (next === script) {
|
|
113
|
+
// Reported, not swallowed. A lookup that quietly finds nothing is how this subsystem has
|
|
114
|
+
// failed three times running: the overlay asks, gets nothing, and says nothing, so the log
|
|
115
|
+
// looks identical whether the file is still parsing or the key is simply wrong. Throttled to
|
|
116
|
+
// once a second because the poll runs four times that.
|
|
117
|
+
val now = SystemClock.uptimeMillis()
|
|
118
|
+
if (now - lastMissLogMs >= MISS_LOG_INTERVAL_MS) {
|
|
119
|
+
lastMissLogMs = now
|
|
120
|
+
Log.w(TAG, "no ASS signs for track $trackId; parsed so far: ${AssSignStore.keys()}")
|
|
121
|
+
}
|
|
122
|
+
return
|
|
123
|
+
}
|
|
109
124
|
// The one line worth keeping. Every failure of this subsystem has been silent — a span dropped
|
|
110
125
|
// by Parcel, a timestamp rejected, a loop that never started — and each time this line would
|
|
111
126
|
// have named the stage that broke.
|
|
@@ -165,21 +180,28 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
165
180
|
|
|
166
181
|
|
|
167
182
|
private var lastLookupMs = 0L
|
|
183
|
+
private var lastMissLogMs = 0L
|
|
168
184
|
|
|
169
185
|
private fun tick() {
|
|
170
186
|
val player = this.player ?: return
|
|
171
187
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
188
|
+
// Polled rather than waited for, on two counts.
|
|
189
|
+
//
|
|
190
|
+
// A script arrives late: the parser runs on another thread whenever the file finishes
|
|
191
|
+
// downloading — about twelve seconds on a 2.7 MB script — and nothing in Media3 announces "a
|
|
192
|
+
// subtitle has been parsed". The cue stream would have, which is exactly what this design gave
|
|
193
|
+
// up.
|
|
194
|
+
//
|
|
195
|
+
// And the track changes underneath: moving to the next episode swaps the selected text track,
|
|
196
|
+
// and `onTracksChanged` is not something to stake the whole feature on. Comparing the track this
|
|
197
|
+
// script came from against the one selected now catches it either way — without that, episode
|
|
198
|
+
// three's signs kept drawing over episode four.
|
|
199
|
+
val now = SystemClock.uptimeMillis()
|
|
200
|
+
if (now - lastLookupMs >= LOOKUP_INTERVAL_MS) {
|
|
179
201
|
lastLookupMs = now
|
|
180
|
-
resolveScript()
|
|
181
|
-
if (script == null) return
|
|
202
|
+
if (script == null || selectedTextTrackId() != scriptTrackId) resolveScript()
|
|
182
203
|
}
|
|
204
|
+
if (script == null) return
|
|
183
205
|
|
|
184
206
|
val script = this.script ?: return
|
|
185
207
|
val positionUs = player.contentPosition * 1000L
|
|
@@ -357,6 +379,9 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
|
|
|
357
379
|
|
|
358
380
|
/** How often to ask the store for a script that has not arrived yet. */
|
|
359
381
|
private const val LOOKUP_INTERVAL_MS = 250L
|
|
382
|
+
|
|
383
|
+
/** How often to say so when the answer is still nothing. */
|
|
384
|
+
private const val MISS_LOG_INTERVAL_MS = 1000L
|
|
360
385
|
private const val OPAQUE = 0xFF000000.toInt()
|
|
361
386
|
|
|
362
387
|
/** Removes the overlay from [playerView], dropping its listener registration with it. */
|
|
@@ -30,38 +30,59 @@ package expo.modules.video.utils
|
|
|
30
30
|
internal object AssSignStore {
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
* every sign in an episode — 12,616 in the file this
|
|
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 =
|
|
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 =
|
|
41
41
|
size > MAX_TRACKS
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A merged track's id carries the index of the source it came from: Media3 hands the overlay
|
|
46
|
+
* `2:expo-subtitle:id:https://…` for the track the parser was given as `expo-subtitle:id:https://…`.
|
|
47
|
+
* Both ends key on the same string only if that prefix is taken off first.
|
|
48
|
+
*
|
|
49
|
+
* Measured on a device, and it is what made every id lookup miss while a fallback quietly covered
|
|
50
|
+
* for it. Stripped repeatedly, since sources can nest, and safe for these ids because
|
|
51
|
+
* `SubtitleSource` always begins them with `expo-subtitle:`, never a digit.
|
|
52
|
+
*/
|
|
53
|
+
private val MERGED_SOURCE_PREFIX = Regex("""^\d+:""")
|
|
54
|
+
|
|
55
|
+
fun normalizeTrackId(trackId: String): String {
|
|
56
|
+
var id = trackId
|
|
57
|
+
while (MERGED_SOURCE_PREFIX.containsMatchIn(id)) id = id.replaceFirst(MERGED_SOURCE_PREFIX, "")
|
|
58
|
+
return id
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
/** Called from the extractor's loading thread. */
|
|
45
62
|
@Synchronized
|
|
46
63
|
fun put(trackId: String, script: AssScript) {
|
|
47
|
-
scripts[trackId] = script
|
|
64
|
+
scripts[normalizeTrackId(trackId)] = script
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
/**
|
|
51
|
-
*
|
|
68
|
+
* Strictly by id. A miss is a miss.
|
|
69
|
+
*
|
|
70
|
+
* This used to fall back to the only script on hand, as insurance against the two ends reading
|
|
71
|
+
* `Format.id` from different places and disagreeing. They do not disagree — the device confirmed
|
|
72
|
+
* the parser and the overlay name the same track — and the insurance turned out to be a bug of its
|
|
73
|
+
* own: opening a second episode misses on the new id, and "the only script on hand" is the
|
|
74
|
+
* *previous* episode's. Its signs then draw over the new one for its whole runtime, because a
|
|
75
|
+
* resolved script stops the overlay from looking again.
|
|
52
76
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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.
|
|
77
|
+
* A miss here is not a failure. It means the file has not finished parsing yet, which on a 2.7 MB
|
|
78
|
+
* script takes about twelve seconds, and the overlay polls until it has.
|
|
59
79
|
*/
|
|
60
80
|
@Synchronized
|
|
61
|
-
fun get(trackId: String?): AssScript? {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
fun get(trackId: String?): AssScript? = trackId?.let { scripts[normalizeTrackId(it)] }
|
|
82
|
+
|
|
83
|
+
/** The keys on hand, for telling a "not parsed yet" miss from a "wrong key" miss. */
|
|
84
|
+
@Synchronized
|
|
85
|
+
fun keys(): List<String> = scripts.keys.toList()
|
|
65
86
|
|
|
66
87
|
@Synchronized
|
|
67
88
|
fun clear() {
|
|
@@ -342,10 +342,13 @@ class StackingSsaParser(
|
|
|
342
342
|
if (trackId == null) return@runCatching Split(data, offset, length)
|
|
343
343
|
|
|
344
344
|
val script = AssSignParser.parse(String(data, offset, length, Charsets.UTF_8))
|
|
345
|
+
// Filed even with no signs in it. The overlay cannot tell "still parsing" from "nothing to
|
|
346
|
+
// draw" by absence alone, and would go on asking — and logging — for the whole episode.
|
|
347
|
+
AssSignStore.put(trackId, script)
|
|
348
|
+
|
|
345
349
|
if (script.signs.isEmpty()) {
|
|
346
350
|
Split(data, offset, length)
|
|
347
351
|
} else {
|
|
348
|
-
AssSignStore.put(trackId, script)
|
|
349
352
|
val dialogue = script.dialogueOnly.toByteArray(Charsets.UTF_8)
|
|
350
353
|
Split(dialogue, 0, dialogue.size)
|
|
351
354
|
}
|
package/package.json
CHANGED