expo-video-subtitle 0.7.0 → 0.7.1

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,29 @@ 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.1
11
+
12
+ ### Fixed
13
+
14
+ - **Playing an `.ass` track crashed the player (Android).** `Regex(""""\\{([^}]*)}""")` in the sign
15
+ parser left the closing brace unescaped. `java.util.regex` accepts that; Android compiles regular
16
+ expressions with **ICU**, which does not, so the pattern threw `PatternSyntaxException` out of a
17
+ static initialiser on the extractor's loading thread and took playback down with it —
18
+ `ExceptionInInitializerError` on the first ASS subtitle the app opened.
19
+
20
+ The whole class of bug is invisible to this module's JVM tests, because the offending pattern
21
+ compiles there. `RegexPortabilityTest` now reads the sources instead and fails on any regex
22
+ literal with a brace that is neither escaped nor inside a character class.
23
+
24
+ - **A subtitle this package cannot read no longer stops the video.** `StackingSsaParser.split` runs
25
+ on the extractor's loading thread, where a thrown exception is not a failed subtitle — it is a
26
+ dead player. It is now wrapped end to end, and a track that fails falls back to Media3's own ASS
27
+ handling, which draws the captions flat. Subtitle files are untrusted scraper output; plain
28
+ captions are the honest failure, a black screen is not.
29
+
30
+ - **A multi-line sign follows its own alignment.** Every block was laid out centred, so the second
31
+ line of a left- or right-anchored placard pulled to the middle.
32
+
10
33
  ## 0.7.0
11
34
 
12
35
  ### Added
@@ -149,9 +149,16 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
149
149
  // Wrapping is the script's job, not ours: a sign is placed by hand and its line breaks are
150
150
  // written into the text as \N. A width of "as wide as it wants" reproduces that.
151
151
  val textWidth = Layout.getDesiredWidth(sign.text, fillPaint).toInt() + 1
152
+ // A multi-line sign wraps its lines the way its own alignment says, not always centred: a
153
+ // left-anchored placard reads wrong with its second line pulled to the middle.
154
+ val blockAlignment = when ((sign.alignment - 1) % 3) {
155
+ 0 -> Layout.Alignment.ALIGN_NORMAL
156
+ 2 -> Layout.Alignment.ALIGN_OPPOSITE
157
+ else -> Layout.Alignment.ALIGN_CENTER
158
+ }
152
159
  val layout = StaticLayout.Builder
153
160
  .obtain(sign.text, 0, sign.text.length, fillPaint, textWidth)
154
- .setAlignment(Layout.Alignment.ALIGN_CENTER)
161
+ .setAlignment(blockAlignment)
155
162
  .setIncludePad(false)
156
163
  .build()
157
164
 
@@ -199,7 +206,7 @@ internal class AssSignOverlay(context: Context) : View(context), Player.Listener
199
206
  // fill afterwards covers that half and leaves the outline the width the script asked for.
200
207
  StaticLayout.Builder
201
208
  .obtain(sign.text, 0, sign.text.length, strokePaint, textWidth)
202
- .setAlignment(Layout.Alignment.ALIGN_CENTER)
209
+ .setAlignment(blockAlignment)
203
210
  .setIncludePad(false)
204
211
  .build()
205
212
  }
@@ -37,8 +37,15 @@ internal object AssSignParser {
37
37
  private const val DEFAULT_PLAY_RES_X = 384f
38
38
  private const val DEFAULT_PLAY_RES_Y = 288f
39
39
 
40
- /** Matches one override block, `{…}`. The contents are scanned separately. */
41
- private val BRACES = Regex("""\{([^}]*)}""")
40
+ /**
41
+ * Matches one override block, `{…}`. The contents are scanned separately.
42
+ *
43
+ * Both braces are escaped. A bare closing brace is legal in `java.util.regex` and a syntax error
44
+ * in ICU, which is the engine Android actually uses — so the unescaped form compiles and passes
45
+ * on a JVM test run, then throws `PatternSyntaxException` from a static initialiser on device and
46
+ * takes the playback thread down with it.
47
+ */
48
+ private val BRACES = Regex("""\{([^}]*)\}""")
42
49
 
43
50
  /**
44
51
  * Splits a script into the part this package draws and the part Media3 draws.
@@ -9,6 +9,7 @@ import androidx.media3.common.Format
9
9
  import androidx.media3.common.MimeTypes
10
10
  import androidx.media3.common.text.Cue
11
11
  import androidx.media3.common.util.Consumer
12
+ import androidx.media3.common.util.Log
12
13
  import androidx.media3.common.util.UnstableApi
13
14
  import androidx.media3.extractor.text.CuesWithTiming
14
15
  import androidx.media3.extractor.text.DefaultSubtitleParserFactory
@@ -337,14 +338,29 @@ class StackingSsaParser(delegate: SubtitleParser) :
337
338
  StackingSubtitleParser(delegate, SsaCueNormalizer::normalize) {
338
339
 
339
340
  override fun split(data: ByteArray, offset: Int, length: Int): Split {
340
- val content = runCatching { String(data, offset, length, Charsets.UTF_8) }.getOrNull()
341
- ?: return Split(data, offset, length, emptyList())
342
-
343
- val script = AssSignParser.parse(content)
344
- if (script.signs.isEmpty()) return Split(data, offset, length, emptyList())
341
+ // Nothing in here is worth a crash, and the whole body is inside the guard rather than just the
342
+ // parse. This runs on the extractor's loading thread, where an exception does not fail the
343
+ // subtitle — it takes playback down with it, which is how a stray `}` in a regex turned into a
344
+ // dead player on an episode that had been playing fine.
345
+ //
346
+ // A subtitle file is untrusted input from a scraper. The honest failure for one this cannot
347
+ // read is captions drawn Media3's own way, flat, rather than no video at all.
348
+ return runCatching {
349
+ val script = AssSignParser.parse(String(data, offset, length, Charsets.UTF_8))
350
+ if (script.signs.isEmpty()) {
351
+ Split(data, offset, length, emptyList())
352
+ } else {
353
+ val dialogue = script.dialogueOnly.toByteArray(Charsets.UTF_8)
354
+ Split(dialogue, 0, dialogue.size, script.signs.map(::toEntry))
355
+ }
356
+ }.getOrElse { error ->
357
+ Log.w(TAG, "Sign rendering unavailable for this track; falling back to Media3", error)
358
+ Split(data, offset, length, emptyList())
359
+ }
360
+ }
345
361
 
346
- val dialogue = script.dialogueOnly.toByteArray(Charsets.UTF_8)
347
- return Split(dialogue, 0, dialogue.size, script.signs.map(::toEntry))
362
+ private companion object {
363
+ const val TAG = "StackingSsaParser"
348
364
  }
349
365
 
350
366
  private fun toEntry(sign: AssSign): PreparedCue {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-video-subtitle",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",