expo-video-subtitle 0.1.0 → 0.1.2

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,7 +7,43 @@ 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.1.0 — Unreleased
10
+ ## 0.1.2
11
+
12
+ ### Fixed
13
+
14
+ - **Android:** subtitles that are on screen at the same time are now stacked as one block instead of
15
+ being drawn on top of each other. `.srt` files legitimately contain overlapping timestamps (two
16
+ speakers, dialogue over on-screen text), but SubRip carries no positioning, so Media3 pinned every
17
+ cue to the same spot and rendered them one over the other — Media3 does the stacking in its WebVTT
18
+ *parser* (`line = -1 - i`) and has no equivalent pass for SubRip. A `SubtitleParser` for SubRip now
19
+ splits the cue timeline at every boundary and merges the cues active in each slice into a single
20
+ multi-line cue, ordered like WebVTT: the cue that started earliest sits at the bottom. This also
21
+ makes Android match the iOS output, which already stacked because sideloaded SRT is converted to
22
+ WebVTT there. Cues with explicit `{\anN}` positioning are left untouched.
23
+ - **Android:** selecting a `subtitleTrack` whose id is not among the available tracks no longer
24
+ leaves the previously selected track on screen. The cleared track-selection parameters were only
25
+ applied when a matching track was found, so an unmatched assignment silently did nothing; the
26
+ selection is now cleared and a warning is logged.
27
+ - **Android:** `subtitleStyle.bottomOffset` is reset to the default when it is omitted, instead of
28
+ keeping the value from a previously applied style.
29
+
30
+ ## 0.1.1
31
+
32
+ ### Fixed
33
+
34
+ - **Android:** `subtitleStyle.fontFamily` now resolves custom fonts bundled with the app, the same way
35
+ React Native resolves a `<Text>` component's `fontFamily`. It is looked up as an Android font
36
+ resource (`res/font`, where `expo-font`'s config plugin puts fonts) and then through React Native's
37
+ font manager, which covers the `assets/fonts/<name>.ttf` convention and fonts registered at runtime.
38
+ Previously only the built-in system families worked: `Typeface.create` silently fell back to the
39
+ default typeface for any bundled font, so `fontFamily` appeared to do nothing on Android while
40
+ working on iOS.
41
+
42
+ ### Documentation
43
+
44
+ - Document font support and the silent-fallback behaviour for unresolved family names.
45
+
46
+ ## 0.1.0
11
47
 
12
48
  Initial release. Based on `expo-video` **56.1.4** (Expo SDK 56).
13
49
 
package/FORK.md CHANGED
@@ -23,7 +23,8 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
23
23
  | `ios/Records/SubtitleStyle.swift` | 118 | Record for caption styling; hex→ARGB parsing; builds `[AVTextStyleRule]`. |
24
24
  | `ios/VideoPlayerSubtitleSideload.swift` | 203 | Builds the `AVMutableComposition`, downloads remote subs, SRT→WebVTT converter. |
25
25
  | `android/…/records/SubtitleSource.kt` | 54 | Record → `MediaItem.SubtitleConfiguration` + MIME resolution. |
26
- | `android/…/records/SubtitleStyle.kt` | 98 | Record → `CaptionStyleCompat`; hex colour parsing. |
26
+ | `android/…/records/SubtitleStyle.kt` | 129 | Record → `CaptionStyleCompat`; hex colour parsing; font-family resolution (`res/font` → `ReactFontManager`). |
27
+ | `android/…/utils/StackingSubtitleParser.kt` | 214 | `SubtitleParser` for SubRip that segments the cue timeline and merges simultaneous cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip to it. |
27
28
  | `FORK.md`, `LICENSE` | — | This guide; MIT licence (upstream copyright preserved). |
28
29
 
29
30
  ### Modified — upstream files carrying fork edits (re-apply these after a sync)
@@ -40,9 +41,11 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
40
41
  | `ios/VideoView.swift` | +8 | `subtitleStyle` property; forward it when `player` is (re)assigned. |
41
42
  | `ios/VideoModule.swift` | +4 | `Prop("subtitleStyle")`. |
42
43
  | `android/…/records/VideoSource.kt` | +9 / −2 | `subtitleTracks` field; `setSubtitleConfigurations(…)` in `toMediaItem()`; subtitle key in `toMediaId()`. |
43
- | `android/…/utils/SubtitleUtils.kt` | +27 / −16 | `configureSubtitleView(…, customStyle)` overload and a `styleProvider` on the captioning listener. Keeps the original behaviour when no custom style is set. |
44
+ | `android/…/utils/SubtitleUtils.kt` | +27 / −16 | `configureSubtitleView(…, customStyle)` overload and a `styleProvider` on the captioning listener. Keeps the original behaviour when no custom style is set. Passes `context` to `toCaptionStyle` for font lookup. |
44
45
  | `android/…/VideoView.kt` | +12 / −5 | `subtitleStyle` property; pass it at all 4 `configureSubtitleView` call sites + the captioning listener. |
45
46
  | `android/…/VideoModule.kt` | +4 | `Prop("subtitleStyle")`. |
47
+ | `android/…/utils/DataSourceUtils.kt` | +5 | `setSubtitleParserFactory(ExpoVideoSubtitleParserFactory())` on the `DefaultMediaSourceFactory`. Note this file declares `package expo.modules.video` despite living in `utils/`, so the factory needs an explicit import. |
48
+ | `android/…/player/VideoPlayerSubtitles.kt` | +14 / −8 | Apply the cleared track-selection parameters when the requested subtitle id matches nothing, instead of only inside the `format?.let` block. |
46
49
  | `android/…/FullscreenPlayerActivity.kt` | +3 / −3 | Pass `videoView.subtitleStyle` at its 2 call sites + the captioning listener. |
47
50
  | `package.json` | — | Fork identity + build scripts — see §3. |
48
51
  | `expo-module.config.json` | — | **Android `publication` block removed** — see §3. |
@@ -60,6 +63,14 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
60
63
  > block, `local-maven-repo/` or `prebuilds/`, the app keeps building — it just runs **upstream's**
61
64
  > video module, and every subtitle feature silently disappears. Verify with §4.
62
65
 
66
+ ### External API coupling to watch
67
+
68
+ `SubtitleStyle.kt` imports `com.facebook.react.common.assets.ReactFontManager` to resolve
69
+ `fontFamily` the same way `<Text>` does. React Native moved this class from
70
+ `com.facebook.react.views.text` to `com.facebook.react.common.assets` (the old path is deprecated but
71
+ still present in RN 0.85). If a React Native upgrade breaks that import, update the package path — the
72
+ call itself, `getTypeface(family, style, assetManager)`, has been stable.
73
+
63
74
  ---
64
75
 
65
76
  ## 2. Syncing a newer `expo-video`
package/README.md CHANGED
@@ -103,6 +103,32 @@ Pass a `subtitleStyle` prop to `VideoView`. It applies to whichever subtitle tra
103
103
 
104
104
  > **iOS note:** styling relies on `textStyleRules`, which only affects WebVTT captions the system renders. The device's **Settings → Accessibility → Subtitles & Captioning** preferences take precedence when enabled. `edgeType`/`edgeColor` and `bottomOffset` are Android-only.
105
105
 
106
+ ### Fonts
107
+
108
+ `fontFamily` accepts the same name you would give a `<Text>` component, so a font bundled with your
109
+ app can be used for subtitles:
110
+
111
+ ```tsx
112
+ <VideoView player={player} subtitleStyle={{ fontFamily: 'Inter-Regular', bold: true }} />
113
+ ```
114
+
115
+ The simplest way to make a custom font available on both platforms is `expo-font`'s config plugin,
116
+ which bundles the file into `res/font` on Android and registers it via `UIAppFonts` on iOS:
117
+
118
+ ```json
119
+ ["expo-font", { "fonts": ["./assets/fonts/Inter-Regular.ttf"] }]
120
+ ```
121
+
122
+ | Font source | Android | iOS |
123
+ | --- | --- | --- |
124
+ | Built-in system families (`sans-serif`, `serif`, `monospace`, `cursive`, …) | ✅ | ✅ (use iOS names, e.g. `Helvetica Neue`) |
125
+ | Bundled via `expo-font` config plugin | ✅ resolved from `res/font` | ✅ resolved from `UIAppFonts` |
126
+ | React Native convention (`assets/fonts/<name>.ttf`) | ✅ via RN's font manager | — |
127
+
128
+ > A font name that matches nothing falls back to the default typeface **silently** — neither platform
129
+ > reports an unresolved family. If the font doesn't change, check the spelling first: the name is the
130
+ > font's family name, not the filename.
131
+
106
132
  ## License
107
133
 
108
134
  MIT. Based on [`expo-video`](https://github.com/expo/expo/tree/main/packages/expo-video) by 650 Industries, Inc.
@@ -1,5 +1,6 @@
1
1
  package expo.modules.video.player
2
2
 
3
+ import android.util.Log
3
4
  import androidx.annotation.OptIn
4
5
  import androidx.media3.common.C
5
6
  import androidx.media3.common.Format
@@ -86,15 +87,24 @@ class VideoPlayerSubtitles(owner: VideoPlayer) : VideoPlayerListener {
86
87
  val format = formatsToGroups.keys.firstOrNull {
87
88
  it.id == subtitleTrack.id
88
89
  }
89
- format?.let {
90
- formatsToGroups[it]?.let { subtitlePair ->
91
- val override = TrackSelectionOverride(subtitlePair.first, subtitlePair.second)
92
- newParameters = newParameters.buildUpon().addOverride(override).build()
93
- player.trackSelectionParameters = newParameters
94
- setSubtitlesEnabled(true)
95
- currentOverride = override
96
- }
90
+ val subtitlePair = format?.let { formatsToGroups[it] }
91
+
92
+ if (subtitlePair == null) {
93
+ // The requested track isn't among the available ones. The cleared parameters still have to be
94
+ // applied, otherwise the previously selected track keeps rendering and the assignment looks
95
+ // like it was silently ignored.
96
+ Log.w("ExpoVideo", "Subtitle track with id '${subtitleTrack.id}' is not available; clearing the current selection")
97
+ player.trackSelectionParameters = newParameters
98
+ setSubtitlesEnabled(false)
99
+ currentOverride = null
100
+ return
97
101
  }
102
+
103
+ val override = TrackSelectionOverride(subtitlePair.first, subtitlePair.second)
104
+ newParameters = newParameters.buildUpon().addOverride(override).build()
105
+ player.trackSelectionParameters = newParameters
106
+ setSubtitlesEnabled(true)
107
+ currentOverride = override
98
108
  }
99
109
 
100
110
  private fun findSelectedSubtitleFormat(): Format? {
@@ -1,10 +1,13 @@
1
1
  package expo.modules.video.records
2
2
 
3
+ import android.content.Context
3
4
  import android.graphics.Color
4
5
  import android.graphics.Typeface
5
6
  import androidx.annotation.OptIn
7
+ import androidx.core.content.res.ResourcesCompat
6
8
  import androidx.media3.common.util.UnstableApi
7
9
  import androidx.media3.ui.CaptionStyleCompat
10
+ import com.facebook.react.common.assets.ReactFontManager
8
11
  import expo.modules.kotlin.records.Field
9
12
  import expo.modules.kotlin.records.Record
10
13
  import java.io.Serializable
@@ -29,22 +32,52 @@ class SubtitleStyle(
29
32
  * Builds a [CaptionStyleCompat], falling back to [base] for any unspecified property so that
30
33
  * partial styles compose on top of the system/default caption style.
31
34
  */
32
- fun toCaptionStyle(base: CaptionStyleCompat): CaptionStyleCompat {
35
+ fun toCaptionStyle(base: CaptionStyleCompat, context: Context): CaptionStyleCompat {
33
36
  return CaptionStyleCompat(
34
37
  parseColor(textColor) ?: base.foregroundColor,
35
38
  parseColor(backgroundColor) ?: base.backgroundColor,
36
39
  parseColor(windowColor) ?: base.windowColor,
37
40
  parseEdgeType() ?: base.edgeType,
38
41
  parseColor(edgeColor) ?: base.edgeColor,
39
- resolveTypeface() ?: base.typeface
42
+ resolveTypeface(context) ?: base.typeface
40
43
  )
41
44
  }
42
45
 
43
- private fun resolveTypeface(): Typeface? {
46
+ /**
47
+ * Resolves [fontFamily] the same way React Native resolves `<Text style={{ fontFamily }} />`, so a
48
+ * font bundled with the app can be used for subtitles under the same name.
49
+ *
50
+ * Lookup order:
51
+ * 1. An Android font resource (`res/font/<name>`), which is where `expo-font`'s config plugin puts fonts.
52
+ * 2. [ReactFontManager], which covers the React Native convention (`assets/fonts/<name>.ttf`) and any
53
+ * font registered at runtime, and itself falls back to the built-in system families.
54
+ *
55
+ * A name that matches nothing still falls back to the default typeface — Android provides no way to
56
+ * detect that, so an unknown family fails silently rather than throwing.
57
+ */
58
+ private fun resolveTypeface(context: Context): Typeface? {
44
59
  val style = if (bold) Typeface.BOLD else Typeface.NORMAL
45
- fontFamily?.let { return Typeface.create(it, style) }
46
- if (bold) return Typeface.DEFAULT_BOLD
47
- return null
60
+ val family = fontFamily?.takeIf { it.isNotBlank() }
61
+ ?: return if (bold) Typeface.DEFAULT_BOLD else null
62
+
63
+ resolveFontResource(context, family)?.let { return Typeface.create(it, style) }
64
+
65
+ return try {
66
+ ReactFontManager.getInstance().getTypeface(family, style, context.assets)
67
+ } catch (e: Exception) {
68
+ // Never let font resolution break subtitle rendering.
69
+ Typeface.create(family, style)
70
+ }
71
+ }
72
+
73
+ private fun resolveFontResource(context: Context, family: String): Typeface? {
74
+ return try {
75
+ @Suppress("DiscouragedApi") // Font names arrive at runtime, so they can't be compile-time resource ids.
76
+ val resId = context.resources.getIdentifier(family.lowercase(), "font", context.packageName)
77
+ if (resId == 0) null else ResourcesCompat.getFont(context, resId)
78
+ } catch (e: Exception) {
79
+ null
80
+ }
48
81
  }
49
82
 
50
83
  private fun parseEdgeType(): Int? = when (edgeType?.lowercase()) {
@@ -13,6 +13,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
13
13
  import androidx.media3.exoplayer.source.MediaSource
14
14
  import expo.modules.video.records.VideoSource
15
15
  import expo.modules.video.managers.VideoManager
16
+ import expo.modules.video.utils.ExpoVideoSubtitleParserFactory
16
17
  import okhttp3.OkHttpClient
17
18
 
18
19
  @OptIn(UnstableApi::class)
@@ -54,7 +55,11 @@ fun buildCacheDataSourceFactory(context: Context, videoSource: VideoSource): Dat
54
55
  }
55
56
 
56
57
  fun buildMediaSourceFactory(context: Context, dataSourceFactory: DataSource.Factory): MediaSource.Factory {
57
- return DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory)
58
+ return DefaultMediaSourceFactory(context)
59
+ .setDataSourceFactory(dataSourceFactory)
60
+ // Lays out simultaneous SubRip cues as one stacked block instead of drawing them on top of
61
+ // each other. See `StackingSubripParser`.
62
+ .setSubtitleParserFactory(ExpoVideoSubtitleParserFactory())
58
63
  }
59
64
 
60
65
  @OptIn(UnstableApi::class)
@@ -0,0 +1,211 @@
1
+ package expo.modules.video.utils
2
+
3
+ import androidx.annotation.OptIn
4
+ import androidx.media3.common.C
5
+ import androidx.media3.common.Format
6
+ import androidx.media3.common.MimeTypes
7
+ import androidx.media3.common.text.Cue
8
+ import androidx.media3.common.util.Consumer
9
+ import androidx.media3.common.util.UnstableApi
10
+ import androidx.media3.extractor.text.CuesWithTiming
11
+ import androidx.media3.extractor.text.DefaultSubtitleParserFactory
12
+ import androidx.media3.extractor.text.SubtitleParser
13
+ import androidx.media3.extractor.text.subrip.SubripParser
14
+
15
+ /**
16
+ * A [SubtitleParser] for SubRip (`.srt`) that lays out simultaneous cues as a single stacked block
17
+ * instead of drawing them on top of each other.
18
+ *
19
+ * ### Why this is needed
20
+ *
21
+ * SubRip carries no positioning information, so every cue Media3's [SubripParser] produces has
22
+ * `line == Cue.DIMEN_UNSET`. `SubtitlePainter` pins every such cue to the same spot
23
+ * (`parentBottom - textHeight - parentHeight * bottomPaddingFraction`) and `CanvasSubtitleOutput`
24
+ * gives each painter the identical layout box, so two cues that are on screen at the same time are
25
+ * rendered in exactly the same place. Media3 performs no collision avoidance: for WebVTT the
26
+ * stacking is done by the *parser* (which assigns `line = -1 - i` per the WebVTT spec), and the
27
+ * SubRip path simply has no equivalent pass. `.srt` files with overlapping timestamps are perfectly
28
+ * legal and common (two speakers, dialogue plus on-screen signs), so the fix belongs here.
29
+ *
30
+ * ### Approach
31
+ *
32
+ * The cue timeline is split at every start/end boundary into non-overlapping segments, and the cues
33
+ * active in a segment are merged into one multi-line cue. Merging rather than assigning `line`
34
+ * values is deliberate: `SubtitlePainter` advances `LINE_TYPE_NUMBER` positions by the height of a
35
+ * cue's *first* line, so a two-line cue at line -1 would still collide with a cue at line -2, and
36
+ * a parser cannot predict where text will wrap. One merged cue is laid out as a single block, which
37
+ * cannot self-overlap, keeps line spacing uniform and lets the text layout handle wrapping.
38
+ *
39
+ * Ordering matches WebVTT (and therefore the iOS behaviour of this package): the cue that started
40
+ * earliest sits at the bottom and later cues stack above it.
41
+ *
42
+ * Cues that *do* carry explicit positioning (SubRip's `{\anN}` tags) are passed through untouched —
43
+ * they were placed deliberately and must not be merged.
44
+ */
45
+ @OptIn(UnstableApi::class)
46
+ class StackingSubripParser : SubtitleParser {
47
+ private val delegate = SubripParser()
48
+
49
+ // Every emitted segment is a complete description of what should be on screen for its interval,
50
+ // so segments replace each other rather than being merged by the renderer.
51
+ override fun getCueReplacementBehavior(): Int = Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE
52
+
53
+ override fun reset() {
54
+ delegate.reset()
55
+ }
56
+
57
+ override fun parse(
58
+ data: ByteArray,
59
+ offset: Int,
60
+ length: Int,
61
+ outputOptions: SubtitleParser.OutputOptions,
62
+ output: Consumer<CuesWithTiming>
63
+ ) {
64
+ val entries = mutableListOf<Entry>()
65
+ // Always ask the delegate for everything; `outputOptions` is applied to the merged result below,
66
+ // because a segment can span cues that fall on both sides of the requested start time.
67
+ delegate.parse(data, offset, length, SubtitleParser.OutputOptions.allCues()) { cuesWithTiming ->
68
+ val startUs = cuesWithTiming.startTimeUs
69
+ val endUs = resolveEndUs(cuesWithTiming)
70
+ for (cue in cuesWithTiming.cues) {
71
+ entries.add(Entry(cue, startUs, endUs, entries.size))
72
+ }
73
+ }
74
+
75
+ if (entries.isEmpty()) {
76
+ return
77
+ }
78
+
79
+ val segments = buildSegments(entries)
80
+ emit(segments, outputOptions, output)
81
+ }
82
+
83
+ private fun resolveEndUs(cuesWithTiming: CuesWithTiming): Long {
84
+ val durationUs = cuesWithTiming.durationUs
85
+ return if (durationUs == C.TIME_UNSET || durationUs <= 0) {
86
+ // Without a duration there is nothing sensible to overlap against; treat it as instantaneous
87
+ // so it never swallows later cues.
88
+ cuesWithTiming.startTimeUs
89
+ } else {
90
+ cuesWithTiming.startTimeUs + durationUs
91
+ }
92
+ }
93
+
94
+ /** Splits the timeline at every cue boundary and merges the cues active within each slice. */
95
+ private fun buildSegments(entries: List<Entry>): List<CuesWithTiming> {
96
+ val boundaries = sortedSetOf<Long>()
97
+ for (entry in entries) {
98
+ boundaries.add(entry.startUs)
99
+ boundaries.add(entry.endUs)
100
+ }
101
+
102
+ val ordered = boundaries.toList()
103
+ val segments = mutableListOf<CuesWithTiming>()
104
+
105
+ for (i in 0 until ordered.size - 1) {
106
+ val segmentStart = ordered[i]
107
+ val segmentEnd = ordered[i + 1]
108
+ if (segmentEnd <= segmentStart) {
109
+ continue
110
+ }
111
+
112
+ // Earliest first, falling back to file order for cues that start together.
113
+ val active = entries
114
+ .filter { it.startUs <= segmentStart && it.endUs > segmentStart }
115
+ .sortedWith(compareBy({ it.startUs }, { it.order }))
116
+
117
+ if (active.isEmpty()) {
118
+ continue
119
+ }
120
+
121
+ val cues = mergeActiveCues(active)
122
+ if (cues.isNotEmpty()) {
123
+ segments.add(CuesWithTiming(cues, segmentStart, segmentEnd - segmentStart))
124
+ }
125
+ }
126
+
127
+ return segments
128
+ }
129
+
130
+ private fun mergeActiveCues(active: List<Entry>): List<Cue> {
131
+ val positioned = active.filter { it.cue.hasExplicitPosition() }
132
+ val unpositioned = active.filter { !it.cue.hasExplicitPosition() }
133
+
134
+ if (unpositioned.isEmpty()) {
135
+ return positioned.map { it.cue }
136
+ }
137
+ if (unpositioned.size == 1) {
138
+ return positioned.map { it.cue } + unpositioned.first().cue
139
+ }
140
+
141
+ // Reversed so the most recent cue is the first rendered line (the top of the block) and the
142
+ // earliest stays at the bottom, matching how WebVTT stacks with line = -1 - i.
143
+ val mergedText = unpositioned
144
+ .asReversed()
145
+ .mapNotNull { it.cue.text?.toString()?.takeIf(String::isNotBlank) }
146
+ .joinToString("\n")
147
+
148
+ if (mergedText.isEmpty()) {
149
+ return positioned.map { it.cue }
150
+ }
151
+
152
+ // Build from the bottom-most cue so any styling it carries survives the merge.
153
+ val template = unpositioned.first().cue
154
+ val merged = template.buildUpon().setText(mergedText).build()
155
+ return positioned.map { it.cue } + merged
156
+ }
157
+
158
+ private fun emit(
159
+ segments: List<CuesWithTiming>,
160
+ outputOptions: SubtitleParser.OutputOptions,
161
+ output: Consumer<CuesWithTiming>
162
+ ) {
163
+ val startTimeUs = outputOptions.startTimeUs
164
+ if (startTimeUs == C.TIME_UNSET) {
165
+ segments.forEach(output::accept)
166
+ return
167
+ }
168
+
169
+ // Cues still visible at, or starting after, the requested time come first.
170
+ segments.filter { it.endTimeUs > startTimeUs }.forEach(output::accept)
171
+ if (outputOptions.outputAllCues) {
172
+ segments.filter { it.endTimeUs <= startTimeUs }.forEach(output::accept)
173
+ }
174
+ }
175
+
176
+ private fun Cue.hasExplicitPosition(): Boolean =
177
+ line != Cue.DIMEN_UNSET || position != Cue.DIMEN_UNSET
178
+
179
+ private data class Entry(
180
+ val cue: Cue,
181
+ val startUs: Long,
182
+ val endUs: Long,
183
+ /** Preserves file order so cues sharing a start time keep a stable, deterministic layout. */
184
+ val order: Int
185
+ )
186
+ }
187
+
188
+ /**
189
+ * Routes SubRip through [StackingSubripParser] so simultaneous cues stack, and delegates every other
190
+ * subtitle format to Media3's default handling.
191
+ */
192
+ @OptIn(UnstableApi::class)
193
+ class ExpoVideoSubtitleParserFactory : SubtitleParser.Factory {
194
+ private val defaultFactory = DefaultSubtitleParserFactory()
195
+
196
+ override fun supportsFormat(format: Format): Boolean =
197
+ format.isSubrip() || defaultFactory.supportsFormat(format)
198
+
199
+ override fun getCueReplacementBehavior(format: Format): Int =
200
+ if (format.isSubrip()) {
201
+ Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE
202
+ } else {
203
+ defaultFactory.getCueReplacementBehavior(format)
204
+ }
205
+
206
+ override fun create(format: Format): SubtitleParser =
207
+ if (format.isSubrip()) StackingSubripParser() else defaultFactory.create(format)
208
+
209
+ private fun Format.isSubrip(): Boolean =
210
+ MimeTypes.APPLICATION_SUBRIP.equals(sampleMimeType, ignoreCase = true)
211
+ }
@@ -5,6 +5,7 @@ import android.util.TypedValue
5
5
  import android.view.accessibility.CaptioningManager
6
6
  import androidx.media3.ui.CaptionStyleCompat
7
7
  import androidx.media3.ui.PlayerView
8
+ import androidx.media3.ui.SubtitleView
8
9
  import expo.modules.video.records.SubtitleStyle
9
10
 
10
11
  object SubtitleUtils {
@@ -29,10 +30,14 @@ object SubtitleUtils {
29
30
 
30
31
  if (customStyle != null) {
31
32
  val base = userStyle?.let { CaptionStyleCompat.createFromCaptionStyle(it) } ?: CaptionStyleCompat.DEFAULT
32
- setStyle(customStyle.toCaptionStyle(base))
33
+ setStyle(customStyle.toCaptionStyle(base, context))
33
34
  // On Android `fontSize` is an absolute sp value; when omitted, fall back to the accessibility-scaled base.
34
35
  setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, customStyle.fontSize ?: (baseFontSize * fontScale))
35
- customStyle.bottomOffset?.let { setBottomPaddingFraction(it.coerceIn(0f, 1f)) }
36
+ // Always assign, so clearing `bottomOffset` restores the default instead of leaving the
37
+ // previously applied fraction in place.
38
+ setBottomPaddingFraction(
39
+ customStyle.bottomOffset?.coerceIn(0f, 1f) ?: SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
40
+ )
36
41
  } else if (userStyle != null) {
37
42
  // Apply system accessibility caption style but with reasonable base font size
38
43
  setStyle(CaptionStyleCompat.createFromCaptionStyle(userStyle))
@@ -225,6 +225,20 @@ export type SubtitleStyle = {
225
225
  fontSize?: number;
226
226
  /**
227
227
  * The name of the font family used to render the subtitles.
228
+ *
229
+ * Custom fonts bundled with the app are supported, using the same name you would pass to a
230
+ * `<Text>` component's `fontFamily`:
231
+ * - On Android the font is looked up as an Android font resource (`res/font`, where `expo-font`'s
232
+ * config plugin places fonts) and then through React Native's font manager, which covers the
233
+ * `assets/fonts/<name>.ttf` convention and fonts registered at runtime.
234
+ * - On iOS the font must be registered with the system, which is what bundling it through
235
+ * `expo-font`'s config plugin (`UIAppFonts`) does.
236
+ *
237
+ * The built-in system families always work — for example `sans-serif`, `sans-serif-medium`,
238
+ * `serif`, `monospace` and `cursive` on Android.
239
+ *
240
+ * > A name that matches no font falls back to the default typeface silently: neither platform
241
+ * > reports an unresolved font family, so double-check the spelling if nothing changes.
228
242
  */
229
243
  fontFamily?: string;
230
244
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"VideoView.types.d.ts","sourceRoot":"","sources":["../src/VideoView.types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"VideoView.types.d.ts","sourceRoot":"","sources":["../src/VideoView.types.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"VideoView.types.js","sourceRoot":"","sources":["../src/VideoView.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { ViewProps } from 'react-native';\n\nimport type { VideoPlayer } from './VideoPlayer.types';\n\n/**\n * Describes how a video should be scaled to fit in a container.\n * - `contain`: The video maintains its aspect ratio and fits inside the container, with possible letterboxing/pillarboxing.\n * - `cover`: The video maintains its aspect ratio and covers the entire container, potentially cropping some portions.\n * - `fill`: The video stretches/squeezes to completely fill the container, potentially causing distortion.\n */\nexport type VideoContentFit = 'contain' | 'cover' | 'fill';\n\n/**\n * Describes the type of the surface used to render the video.\n * - `surfaceView`: Uses the `SurfaceView` to render the video. This value should be used in the majority of cases. Provides significantly lower power consumption, better performance, and more features.\n * - `textureView`: Uses the `TextureView` to render the video. Should be used in cases where the SurfaceView is not supported or causes issues (for example, overlapping video views).\n *\n * You can learn more about surface types in the official [ExoPlayer documentation](https://developer.android.com/media/media3/ui/playerview#surfacetype).\n * @platform android\n */\nexport type SurfaceType = 'textureView' | 'surfaceView';\n\nexport interface VideoViewProps extends ViewProps {\n /**\n * A video player instance. Use [`useVideoPlayer()`](#usevideoplayersource-setup) hook to create one.\n */\n player?: VideoPlayer | null;\n\n /**\n * Determines whether native controls should be displayed or not.\n *\n * > **Note**: Due to platform limitations, the native controls are always enabled in fullscreen mode.\n * @default true\n */\n nativeControls?: boolean;\n\n /**\n * Describes how the video should be scaled to fit in the container.\n * Options are `'contain'`, `'cover'`, and `'fill'`.\n * @default 'contain'\n */\n contentFit?: VideoContentFit;\n\n /**\n * Determines the fullscreen mode options.\n */\n fullscreenOptions?: FullscreenOptions;\n\n /**\n * Determines whether the timecodes should be displayed or not.\n * @default true\n * @platform ios\n */\n showsTimecodes?: boolean;\n\n /**\n * Determines whether the player allows the user to skip media content.\n * @default false\n * @platform android\n * @platform ios\n */\n requiresLinearPlayback?: boolean;\n\n /**\n * Configuration for controlling the visibility of player control buttons.\n * @platform android\n */\n buttonOptions?: ButtonOptions;\n\n /**\n * Customizes the appearance of the rendered subtitles/captions (text color, background, font, edge, and position).\n *\n * The style is applied to whatever subtitle track is currently displayed, regardless of whether it comes\n * from an embedded (`HLS`/`DASH`) track or an external one attached through\n * [`VideoSource.subtitleTracks`](#videosourcesubtitletracks).\n *\n * > **Note (iOS):** styling relies on `AVPlayerItem.textStyleRules`, which only affects `WebVTT` captions that\n * > the system renders itself. The OS-level *Settings → Accessibility → Subtitles & Captioning* preferences\n * > take precedence over these values when the user has enabled them.\n *\n * @platform android\n * @platform ios\n */\n subtitleStyle?: SubtitleStyle;\n\n /**\n * Determines the type of the surface used to render the video.\n * > This prop should not be changed at runtime.\n * @default 'surfaceView'\n * @platform android\n */\n surfaceType?: SurfaceType;\n\n /**\n * Determines the position offset of the video inside the container.\n * @default { dx: 0, dy: 0 }\n * @platform ios\n */\n contentPosition?: { dx?: number; dy?: number };\n\n /**\n * A callback to call after the video player enters Picture in Picture (PiP) mode.\n * @platform android\n * @platform ios\n * @platform web\n */\n onPictureInPictureStart?: () => void;\n\n /**\n * A callback to call after the video player exits Picture in Picture (PiP) mode.\n * @platform android\n * @platform ios\n * @platform web\n */\n onPictureInPictureStop?: () => void;\n\n /**\n * Determines whether the player allows Picture in Picture (PiP) mode.\n * > **Note:** The `supportsPictureInPicture` property of the [config plugin](#configuration-in-app-config)\n * > has to be configured for the PiP to work.\n * @platform android\n * @platform ios\n * @platform web\n */\n allowsPictureInPicture?: boolean;\n\n /**\n * Determines whether a video should be played \"inline\", that is, within the element's playback area.\n * @platform web\n */\n playsInline?: boolean;\n\n /**\n * Determines whether the player should start Picture in Picture (PiP) automatically when the app is in the background.\n * > **Note:** Only one player can be in Picture in Picture (PiP) mode at a time.\n *\n * > **Note:** The `supportsPictureInPicture` property of the [config plugin](#configuration-in-app-config)\n * > has to be configured for the PiP to work.\n *\n * @default false\n * @platform android 12+\n * @platform ios\n */\n startsPictureInPictureAutomatically?: boolean;\n\n /**\n * Specifies whether to perform video frame analysis (Live Text in videos).\n * Check official [Apple documentation](https://developer.apple.com/documentation/avkit/avplayerviewcontroller/allowsvideoframeanalysis) for more details.\n * @default true\n * @platform ios 16.0+\n */\n allowsVideoFrameAnalysis?: boolean;\n\n /**\n * A callback to call after the video player enters fullscreen mode.\n */\n onFullscreenEnter?: () => void;\n\n /**\n * A callback to call after the video player exits fullscreen mode.\n */\n onFullscreenExit?: () => void;\n\n /**\n * A callback to call after the mounted `VideoPlayer` has rendered the first frame into the `VideoView`.\n * This event can be used to hide any cover images that conceal the initial loading of the player.\n * > **Note:** This event may also be called during playback when the current video track changes (for example when the player switches video quality).\n */\n onFirstFrameRender?: () => void;\n\n /**\n * Determines whether the player should use the default ExoPlayer shutter that covers the `VideoView` before the first video frame is rendered.\n * Setting this property to `false` makes the Android behavior the same as iOS.\n *\n * @platform android\n * @default false\n */\n useExoShutter?: boolean;\n\n /**\n * Determines the [cross origin policy](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/crossorigin) used by the underlying native view on web.\n * If `undefined` (default), does not use CORS at all. If set to `'anonymous'`, the video will be loaded with CORS enabled.\n * Note that some videos may not play if CORS is enabled, depending on the CDN settings.\n * If you encounter issues, consider adjusting the `crossOrigin` property.\n *\n *\n * @platform web\n * @default undefined\n */\n crossOrigin?: 'anonymous' | 'use-credentials';\n\n /**\n * Use Audio Nodes for sound playback. When the same player is playing in multiple video views the audio won't increase in volume\n * as the number of players increases.\n *\n * > **Note**: This property is experimental, when enabled it is known to break audio for some sources. Do not change this property at runtime.\n *\n * @experimental\n * @default false\n * @platform web\n */\n useAudioNodePlayback?: boolean;\n}\n\n/**\n * Describes the edge (outline) effect drawn around subtitle text.\n * - `none`: No edge effect.\n * - `outline`: A solid outline around each glyph.\n * - `dropShadow`: A drop shadow behind the text.\n * - `raised`: The text appears raised from the background.\n * - `depressed`: The text appears pressed into the background.\n */\nexport type SubtitleEdgeType = 'none' | 'outline' | 'dropShadow' | 'raised' | 'depressed';\n\n/**\n * Describes the visual style applied to the rendered subtitles/captions.\n *\n * Colors accept a hex string in the form `#RGB`, `#RRGGBB`, or `#RRGGBBAA` (an alpha suffix controls opacity).\n *\n * @platform android\n * @platform ios\n */\nexport type SubtitleStyle = {\n /**\n * The color of the subtitle text.\n * @default '#FFFFFF'\n */\n textColor?: string;\n\n /**\n * The background color drawn directly behind the text glyphs (the caption \"box\").\n * Use an alpha suffix (for example `#000000A0`) for a translucent box.\n * @default 'transparent'\n */\n backgroundColor?: string;\n\n /**\n * The background color of the whole cue region (the \"window\" behind the whole line).\n * @default 'transparent'\n */\n windowColor?: string;\n\n /**\n * The subtitle font size.\n *\n * > The unit differs per platform: on Android this is an absolute size in `sp`; on iOS it is a percentage\n * > relative to the default caption size (`100` = default, `150` = 1.5×).\n *\n * @platform android\n * @platform ios\n */\n fontSize?: number;\n\n /**\n * The name of the font family used to render the subtitles.\n */\n fontFamily?: string;\n\n /**\n * Whether the subtitle text should be rendered in bold.\n * @default false\n */\n bold?: boolean;\n\n /**\n * The edge (outline) effect drawn around the text.\n * @default 'none'\n */\n edgeType?: SubtitleEdgeType;\n\n /**\n * The color of the edge effect. Only has an effect when `edgeType` is not `'none'`.\n */\n edgeColor?: string;\n\n /**\n * The distance of the subtitles from the bottom of the video, expressed as a fraction of the view height (`0`–`1`).\n * @platform android\n */\n bottomOffset?: number;\n};\n\n/**\n * Configuration for controlling the visibility of player control buttons.\n *\n * > The fullscreen button should be controlled with [`fullscreenOptions.enable`](#fullscreenoptions).\n *\n * @platform android\n */\nexport type ButtonOptions = {\n /**\n * Whether to show the next button.\n * @default false\n */\n showNext?: boolean;\n /**\n * Whether to show the previous button.\n * @default false\n */\n showPrevious?: boolean;\n /**\n * Whether to show the seek forward button.\n * @default true\n */\n showSeekForward?: boolean;\n /**\n * Whether to show the seek backward button.\n * @default true\n */\n showSeekBackward?: boolean;\n /**\n * Whether to show the subtitles button.\n * - `true`: Button is always visible\n * - `false`: Button is never visible\n * - `undefined`: Button is visible only when subtitles are available (default behavior)\n * @default undefined\n */\n showSubtitles?: boolean | null;\n /**\n * Whether to show the settings button.\n * @default true\n */\n showSettings?: boolean;\n /**\n * Whether to show the play/pause button.\n * @default true\n */\n showPlayPause?: boolean;\n /**\n * Whether to show the bottom control bar (containing time, progress bar, and buttons).\n * When set to `false`, the entire bottom bar including the progress bar will be hidden.\n *\n * > **Note**: The bottom bar is always visible in fullscreen mode to allow users to exit fullscreen.\n *\n * @default true\n */\n showBottomBar?: boolean;\n};\n\n/**\n * Describes the orientation of the video in fullscreen mode. Available values are:\n * - `default`: The video is displayed in any of the available device rotations.\n * - `portrait`: The video is displayed in one of two available portrait orientations and rotates between them.\n * - `portraitUp`: The video is displayed in the portrait orientation - the notch of the phone points upwards.\n * - `portraitDown`: The video is displayed in the portrait orientation - the notch of the phone points downwards.\n * - `landscape`: The video is displayed in one of two available landscape orientations and rotates between them.\n * - `landscapeLeft`: The video is displayed in the left landscape orientation - the notch of the phone is in the left palm of the user.\n * - `landscapeRight`: The video is displayed in the right landscape orientation - the notch of the phone is in the right palm of the user.\n */\nexport type FullscreenOrientation =\n | 'default'\n | 'portrait'\n | 'portraitUp'\n | 'portraitDown'\n | 'landscape'\n | 'landscapeLeft'\n | 'landscapeRight';\n\n/**\n * Determines whether the player keeps fullscreen when Picture in Picture (PiP) stops.\n * Only has an effect if the player was in fullscreen when PiP started.\n * - `'always'`: Always re-enter fullscreen when PiP stops.\n * - `'autoEnter'`: Re-enter fullscreen only when PiP was started automatically by the app going to the background.\n * - `'never'`: Do not re-enter fullscreen when PiP stops.\n */\nexport type KeepFullscreenOnPiPStopBehavior = 'always' | 'autoEnter' | 'never';\n\n/**\n * Describes the options for fullscreen video mode.\n */\nexport type FullscreenOptions = {\n /**\n * Specifies whether the fullscreen mode should be available to the user. When `false`, the fullscreen button will be hidden in the player.\n * @default true\n */\n enable: boolean;\n /**\n * Specifies the orientation of the video in fullscreen mode.\n * @default 'default'\n * @platform android\n * @platform ios\n */\n orientation?: FullscreenOrientation;\n /**\n * Specifies whether the app should exit fullscreen mode when the device is rotated to a different orientation than the one specified in the `orientation` prop.\n * For example, if the `orientation` prop is set to `landscape` and the device is rotated to `portrait`, the app will exit fullscreen mode.\n *\n * > This prop will have no effect if the `orientation` prop is set to `default`.\n * > The `VideoView` will never auto-exit fullscreen when the device auto-rotate feature has been disabled in settings.\n *\n * @default false\n * @platform android\n * @platform ios\n */\n autoExitOnRotate?: boolean;\n\n /**\n * Determines whether the player keeps fullscreen when Picture in Picture (PiP) stops.\n * Only has an effect if the player was in fullscreen when PiP started.\n *\n * @default 'autoEnter'\n * @platform ios\n */\n keepFullscreenOnPiPStop?: KeepFullscreenOnPiPStopBehavior;\n};\n"]}
1
+ {"version":3,"file":"VideoView.types.js","sourceRoot":"","sources":["../src/VideoView.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { ViewProps } from 'react-native';\n\nimport type { VideoPlayer } from './VideoPlayer.types';\n\n/**\n * Describes how a video should be scaled to fit in a container.\n * - `contain`: The video maintains its aspect ratio and fits inside the container, with possible letterboxing/pillarboxing.\n * - `cover`: The video maintains its aspect ratio and covers the entire container, potentially cropping some portions.\n * - `fill`: The video stretches/squeezes to completely fill the container, potentially causing distortion.\n */\nexport type VideoContentFit = 'contain' | 'cover' | 'fill';\n\n/**\n * Describes the type of the surface used to render the video.\n * - `surfaceView`: Uses the `SurfaceView` to render the video. This value should be used in the majority of cases. Provides significantly lower power consumption, better performance, and more features.\n * - `textureView`: Uses the `TextureView` to render the video. Should be used in cases where the SurfaceView is not supported or causes issues (for example, overlapping video views).\n *\n * You can learn more about surface types in the official [ExoPlayer documentation](https://developer.android.com/media/media3/ui/playerview#surfacetype).\n * @platform android\n */\nexport type SurfaceType = 'textureView' | 'surfaceView';\n\nexport interface VideoViewProps extends ViewProps {\n /**\n * A video player instance. Use [`useVideoPlayer()`](#usevideoplayersource-setup) hook to create one.\n */\n player?: VideoPlayer | null;\n\n /**\n * Determines whether native controls should be displayed or not.\n *\n * > **Note**: Due to platform limitations, the native controls are always enabled in fullscreen mode.\n * @default true\n */\n nativeControls?: boolean;\n\n /**\n * Describes how the video should be scaled to fit in the container.\n * Options are `'contain'`, `'cover'`, and `'fill'`.\n * @default 'contain'\n */\n contentFit?: VideoContentFit;\n\n /**\n * Determines the fullscreen mode options.\n */\n fullscreenOptions?: FullscreenOptions;\n\n /**\n * Determines whether the timecodes should be displayed or not.\n * @default true\n * @platform ios\n */\n showsTimecodes?: boolean;\n\n /**\n * Determines whether the player allows the user to skip media content.\n * @default false\n * @platform android\n * @platform ios\n */\n requiresLinearPlayback?: boolean;\n\n /**\n * Configuration for controlling the visibility of player control buttons.\n * @platform android\n */\n buttonOptions?: ButtonOptions;\n\n /**\n * Customizes the appearance of the rendered subtitles/captions (text color, background, font, edge, and position).\n *\n * The style is applied to whatever subtitle track is currently displayed, regardless of whether it comes\n * from an embedded (`HLS`/`DASH`) track or an external one attached through\n * [`VideoSource.subtitleTracks`](#videosourcesubtitletracks).\n *\n * > **Note (iOS):** styling relies on `AVPlayerItem.textStyleRules`, which only affects `WebVTT` captions that\n * > the system renders itself. The OS-level *Settings → Accessibility → Subtitles & Captioning* preferences\n * > take precedence over these values when the user has enabled them.\n *\n * @platform android\n * @platform ios\n */\n subtitleStyle?: SubtitleStyle;\n\n /**\n * Determines the type of the surface used to render the video.\n * > This prop should not be changed at runtime.\n * @default 'surfaceView'\n * @platform android\n */\n surfaceType?: SurfaceType;\n\n /**\n * Determines the position offset of the video inside the container.\n * @default { dx: 0, dy: 0 }\n * @platform ios\n */\n contentPosition?: { dx?: number; dy?: number };\n\n /**\n * A callback to call after the video player enters Picture in Picture (PiP) mode.\n * @platform android\n * @platform ios\n * @platform web\n */\n onPictureInPictureStart?: () => void;\n\n /**\n * A callback to call after the video player exits Picture in Picture (PiP) mode.\n * @platform android\n * @platform ios\n * @platform web\n */\n onPictureInPictureStop?: () => void;\n\n /**\n * Determines whether the player allows Picture in Picture (PiP) mode.\n * > **Note:** The `supportsPictureInPicture` property of the [config plugin](#configuration-in-app-config)\n * > has to be configured for the PiP to work.\n * @platform android\n * @platform ios\n * @platform web\n */\n allowsPictureInPicture?: boolean;\n\n /**\n * Determines whether a video should be played \"inline\", that is, within the element's playback area.\n * @platform web\n */\n playsInline?: boolean;\n\n /**\n * Determines whether the player should start Picture in Picture (PiP) automatically when the app is in the background.\n * > **Note:** Only one player can be in Picture in Picture (PiP) mode at a time.\n *\n * > **Note:** The `supportsPictureInPicture` property of the [config plugin](#configuration-in-app-config)\n * > has to be configured for the PiP to work.\n *\n * @default false\n * @platform android 12+\n * @platform ios\n */\n startsPictureInPictureAutomatically?: boolean;\n\n /**\n * Specifies whether to perform video frame analysis (Live Text in videos).\n * Check official [Apple documentation](https://developer.apple.com/documentation/avkit/avplayerviewcontroller/allowsvideoframeanalysis) for more details.\n * @default true\n * @platform ios 16.0+\n */\n allowsVideoFrameAnalysis?: boolean;\n\n /**\n * A callback to call after the video player enters fullscreen mode.\n */\n onFullscreenEnter?: () => void;\n\n /**\n * A callback to call after the video player exits fullscreen mode.\n */\n onFullscreenExit?: () => void;\n\n /**\n * A callback to call after the mounted `VideoPlayer` has rendered the first frame into the `VideoView`.\n * This event can be used to hide any cover images that conceal the initial loading of the player.\n * > **Note:** This event may also be called during playback when the current video track changes (for example when the player switches video quality).\n */\n onFirstFrameRender?: () => void;\n\n /**\n * Determines whether the player should use the default ExoPlayer shutter that covers the `VideoView` before the first video frame is rendered.\n * Setting this property to `false` makes the Android behavior the same as iOS.\n *\n * @platform android\n * @default false\n */\n useExoShutter?: boolean;\n\n /**\n * Determines the [cross origin policy](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/crossorigin) used by the underlying native view on web.\n * If `undefined` (default), does not use CORS at all. If set to `'anonymous'`, the video will be loaded with CORS enabled.\n * Note that some videos may not play if CORS is enabled, depending on the CDN settings.\n * If you encounter issues, consider adjusting the `crossOrigin` property.\n *\n *\n * @platform web\n * @default undefined\n */\n crossOrigin?: 'anonymous' | 'use-credentials';\n\n /**\n * Use Audio Nodes for sound playback. When the same player is playing in multiple video views the audio won't increase in volume\n * as the number of players increases.\n *\n * > **Note**: This property is experimental, when enabled it is known to break audio for some sources. Do not change this property at runtime.\n *\n * @experimental\n * @default false\n * @platform web\n */\n useAudioNodePlayback?: boolean;\n}\n\n/**\n * Describes the edge (outline) effect drawn around subtitle text.\n * - `none`: No edge effect.\n * - `outline`: A solid outline around each glyph.\n * - `dropShadow`: A drop shadow behind the text.\n * - `raised`: The text appears raised from the background.\n * - `depressed`: The text appears pressed into the background.\n */\nexport type SubtitleEdgeType = 'none' | 'outline' | 'dropShadow' | 'raised' | 'depressed';\n\n/**\n * Describes the visual style applied to the rendered subtitles/captions.\n *\n * Colors accept a hex string in the form `#RGB`, `#RRGGBB`, or `#RRGGBBAA` (an alpha suffix controls opacity).\n *\n * @platform android\n * @platform ios\n */\nexport type SubtitleStyle = {\n /**\n * The color of the subtitle text.\n * @default '#FFFFFF'\n */\n textColor?: string;\n\n /**\n * The background color drawn directly behind the text glyphs (the caption \"box\").\n * Use an alpha suffix (for example `#000000A0`) for a translucent box.\n * @default 'transparent'\n */\n backgroundColor?: string;\n\n /**\n * The background color of the whole cue region (the \"window\" behind the whole line).\n * @default 'transparent'\n */\n windowColor?: string;\n\n /**\n * The subtitle font size.\n *\n * > The unit differs per platform: on Android this is an absolute size in `sp`; on iOS it is a percentage\n * > relative to the default caption size (`100` = default, `150` = 1.5×).\n *\n * @platform android\n * @platform ios\n */\n fontSize?: number;\n\n /**\n * The name of the font family used to render the subtitles.\n *\n * Custom fonts bundled with the app are supported, using the same name you would pass to a\n * `<Text>` component's `fontFamily`:\n * - On Android the font is looked up as an Android font resource (`res/font`, where `expo-font`'s\n * config plugin places fonts) and then through React Native's font manager, which covers the\n * `assets/fonts/<name>.ttf` convention and fonts registered at runtime.\n * - On iOS the font must be registered with the system, which is what bundling it through\n * `expo-font`'s config plugin (`UIAppFonts`) does.\n *\n * The built-in system families always work — for example `sans-serif`, `sans-serif-medium`,\n * `serif`, `monospace` and `cursive` on Android.\n *\n * > A name that matches no font falls back to the default typeface silently: neither platform\n * > reports an unresolved font family, so double-check the spelling if nothing changes.\n */\n fontFamily?: string;\n\n /**\n * Whether the subtitle text should be rendered in bold.\n * @default false\n */\n bold?: boolean;\n\n /**\n * The edge (outline) effect drawn around the text.\n * @default 'none'\n */\n edgeType?: SubtitleEdgeType;\n\n /**\n * The color of the edge effect. Only has an effect when `edgeType` is not `'none'`.\n */\n edgeColor?: string;\n\n /**\n * The distance of the subtitles from the bottom of the video, expressed as a fraction of the view height (`0`–`1`).\n * @platform android\n */\n bottomOffset?: number;\n};\n\n/**\n * Configuration for controlling the visibility of player control buttons.\n *\n * > The fullscreen button should be controlled with [`fullscreenOptions.enable`](#fullscreenoptions).\n *\n * @platform android\n */\nexport type ButtonOptions = {\n /**\n * Whether to show the next button.\n * @default false\n */\n showNext?: boolean;\n /**\n * Whether to show the previous button.\n * @default false\n */\n showPrevious?: boolean;\n /**\n * Whether to show the seek forward button.\n * @default true\n */\n showSeekForward?: boolean;\n /**\n * Whether to show the seek backward button.\n * @default true\n */\n showSeekBackward?: boolean;\n /**\n * Whether to show the subtitles button.\n * - `true`: Button is always visible\n * - `false`: Button is never visible\n * - `undefined`: Button is visible only when subtitles are available (default behavior)\n * @default undefined\n */\n showSubtitles?: boolean | null;\n /**\n * Whether to show the settings button.\n * @default true\n */\n showSettings?: boolean;\n /**\n * Whether to show the play/pause button.\n * @default true\n */\n showPlayPause?: boolean;\n /**\n * Whether to show the bottom control bar (containing time, progress bar, and buttons).\n * When set to `false`, the entire bottom bar including the progress bar will be hidden.\n *\n * > **Note**: The bottom bar is always visible in fullscreen mode to allow users to exit fullscreen.\n *\n * @default true\n */\n showBottomBar?: boolean;\n};\n\n/**\n * Describes the orientation of the video in fullscreen mode. Available values are:\n * - `default`: The video is displayed in any of the available device rotations.\n * - `portrait`: The video is displayed in one of two available portrait orientations and rotates between them.\n * - `portraitUp`: The video is displayed in the portrait orientation - the notch of the phone points upwards.\n * - `portraitDown`: The video is displayed in the portrait orientation - the notch of the phone points downwards.\n * - `landscape`: The video is displayed in one of two available landscape orientations and rotates between them.\n * - `landscapeLeft`: The video is displayed in the left landscape orientation - the notch of the phone is in the left palm of the user.\n * - `landscapeRight`: The video is displayed in the right landscape orientation - the notch of the phone is in the right palm of the user.\n */\nexport type FullscreenOrientation =\n | 'default'\n | 'portrait'\n | 'portraitUp'\n | 'portraitDown'\n | 'landscape'\n | 'landscapeLeft'\n | 'landscapeRight';\n\n/**\n * Determines whether the player keeps fullscreen when Picture in Picture (PiP) stops.\n * Only has an effect if the player was in fullscreen when PiP started.\n * - `'always'`: Always re-enter fullscreen when PiP stops.\n * - `'autoEnter'`: Re-enter fullscreen only when PiP was started automatically by the app going to the background.\n * - `'never'`: Do not re-enter fullscreen when PiP stops.\n */\nexport type KeepFullscreenOnPiPStopBehavior = 'always' | 'autoEnter' | 'never';\n\n/**\n * Describes the options for fullscreen video mode.\n */\nexport type FullscreenOptions = {\n /**\n * Specifies whether the fullscreen mode should be available to the user. When `false`, the fullscreen button will be hidden in the player.\n * @default true\n */\n enable: boolean;\n /**\n * Specifies the orientation of the video in fullscreen mode.\n * @default 'default'\n * @platform android\n * @platform ios\n */\n orientation?: FullscreenOrientation;\n /**\n * Specifies whether the app should exit fullscreen mode when the device is rotated to a different orientation than the one specified in the `orientation` prop.\n * For example, if the `orientation` prop is set to `landscape` and the device is rotated to `portrait`, the app will exit fullscreen mode.\n *\n * > This prop will have no effect if the `orientation` prop is set to `default`.\n * > The `VideoView` will never auto-exit fullscreen when the device auto-rotate feature has been disabled in settings.\n *\n * @default false\n * @platform android\n * @platform ios\n */\n autoExitOnRotate?: boolean;\n\n /**\n * Determines whether the player keeps fullscreen when Picture in Picture (PiP) stops.\n * Only has an effect if the player was in fullscreen when PiP started.\n *\n * @default 'autoEnter'\n * @platform ios\n */\n keepFullscreenOnPiPStop?: KeepFullscreenOnPiPStopBehavior;\n};\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-video-subtitle",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -0,0 +1 @@
1
+ {"root":["./src/index.ts","./src/withexpovideo.ts"],"version":"5.9.3"}
@@ -253,6 +253,20 @@ export type SubtitleStyle = {
253
253
 
254
254
  /**
255
255
  * The name of the font family used to render the subtitles.
256
+ *
257
+ * Custom fonts bundled with the app are supported, using the same name you would pass to a
258
+ * `<Text>` component's `fontFamily`:
259
+ * - On Android the font is looked up as an Android font resource (`res/font`, where `expo-font`'s
260
+ * config plugin places fonts) and then through React Native's font manager, which covers the
261
+ * `assets/fonts/<name>.ttf` convention and fonts registered at runtime.
262
+ * - On iOS the font must be registered with the system, which is what bundling it through
263
+ * `expo-font`'s config plugin (`UIAppFonts`) does.
264
+ *
265
+ * The built-in system families always work — for example `sans-serif`, `sans-serif-medium`,
266
+ * `serif`, `monospace` and `cursive` on Android.
267
+ *
268
+ * > A name that matches no font falls back to the default typeface silently: neither platform
269
+ * > reports an unresolved font family, so double-check the spelling if nothing changes.
256
270
  */
257
271
  fontFamily?: string;
258
272