expo-video-subtitle 0.5.0 → 0.7.0
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 +87 -0
- package/FORK.md +5 -1
- package/android/build.gradle +5 -0
- package/android/src/main/java/expo/modules/video/utils/AssSignOverlay.kt +269 -0
- package/android/src/main/java/expo/modules/video/utils/AssSignParser.kt +503 -0
- package/android/src/main/java/expo/modules/video/utils/AssSignSpan.kt +23 -0
- package/android/src/main/java/expo/modules/video/utils/RoundedSubtitleBackground.kt +3 -0
- package/android/src/main/java/expo/modules/video/utils/SsaCueNormalizer.kt +93 -0
- package/android/src/main/java/expo/modules/video/utils/StackingSubtitleParser.kt +117 -22
- package/android/src/main/java/expo/modules/video/utils/SubtitleSpanFilter.kt +25 -4
- package/android/src/main/java/expo/modules/video/utils/SubtitleUtils.kt +7 -0
- package/ios/SubtitleConverterText.swift +30 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,93 @@ 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.0
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **ASS signs are drawn with the transforms the script asks for (Android).** A typeset sign is
|
|
15
|
+
placed on the artwork, and the artwork is usually not square to the screen: in one episode of
|
|
16
|
+
Jujutsu Kaisen S3, 3,723 events carry `\frz`, 1,304 of them past 5° and 104 past 45°, because the
|
|
17
|
+
placards they sit on are drawn at an angle. Rendered flat, the translation reads as pasted over
|
|
18
|
+
the picture instead of lying on it.
|
|
19
|
+
|
|
20
|
+
Media3 cannot do this and has no room to. `SubtitlePainter` contains no call to `rotate`, `skew`,
|
|
21
|
+
`Matrix` or `Camera`; `Cue` carries no rotation field, only `shearDegrees`, which the painter
|
|
22
|
+
ignores; and `SsaParser` discards every override block except `\pos`, `\move` and `\an`.
|
|
23
|
+
|
|
24
|
+
So signs leave Media3's hands entirely. `AssSignParser` splits a script in two — typeset events
|
|
25
|
+
to us, dialogue to the delegate, with the sign lines cut out of the copy the delegate parses, so
|
|
26
|
+
double-drawing is structurally impossible rather than merely avoided. Each sign rides the cue
|
|
27
|
+
stream as a zero-width placeholder carrying an `AssSignSpan`, which buys Media3's timing for free,
|
|
28
|
+
and `AssSignOverlay` draws it on a canvas in front of the captions.
|
|
29
|
+
|
|
30
|
+
Read and honoured: `\pos`, `\move` (at its destination), `\org`, `\an` and the legacy `\a`,
|
|
31
|
+
`\frz`, `\frx`, `\fry`, `\fscx`, `\fscy`, `\fs`, `\c` / `\1c`, `\alpha` / `\1a`, `\b`,
|
|
32
|
+
`\i`, `\u`, style `Alignment`, `Fontsize`, `PrimaryColour`, `OutlineColour`, `Outline`,
|
|
33
|
+
`ScaleX/Y`, `Angle` and the margins, plus `PlayResX` / `PlayResY` and VSFilter's 4:3 rule when one
|
|
34
|
+
is missing.
|
|
35
|
+
|
|
36
|
+
The overlay sits in the player's content frame, which is sized to the video rather than the view,
|
|
37
|
+
so positions survive letterboxing.
|
|
38
|
+
|
|
39
|
+
### Known limitations
|
|
40
|
+
|
|
41
|
+
- `\t` animation, `\fad`, `\clip`, `\blur`, `\be`, `\fax` shear, vector drawings and karaoke are
|
|
42
|
+
still dropped. Reproducing those means libass, not a larger subset of it.
|
|
43
|
+
- Fonts fall back to the system face: a script naming `Bogart Semibold` gets the default at the
|
|
44
|
+
right size and weight, not that typeface.
|
|
45
|
+
- `\frx` and `\fry` are applied with an unverified sign convention. They are almost always tiny —
|
|
46
|
+
3,363 of 5,126 `\frx` values and 2,715 of 3,579 `\fry` values in that episode are under 3° — so a
|
|
47
|
+
wrong sign is subtle rather than visible. `\frz` is what carries the effect and is verified
|
|
48
|
+
against the source line it came from.
|
|
49
|
+
- iOS still draws signs flat. AVFoundation renders WebVTT itself and has no rotation to give.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- The module now has JVM unit tests (`./gradlew :expo-video-subtitle:testDebugUnitTest`). The sign
|
|
54
|
+
parser is pure Kotlin with no Android imports precisely so it can be tested this way: its tag
|
|
55
|
+
scanner and colour handling fail by drawing a sign in the wrong place, which nothing else catches.
|
|
56
|
+
|
|
57
|
+
## 0.6.0
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **ASS dialogue keeps working with `bottomOffset` and with cue stacking again (Android).** Media3's
|
|
62
|
+
`SsaParser` calls `setLine` and `setPosition` on *every* cue, including the bottom-centre dialogue
|
|
63
|
+
the author placed nothing on. `SubtitlePainter` applies `bottomPaddingFraction` only to cues whose
|
|
64
|
+
`line` is unset, so `SubtitleSource.bottomOffset` silently stopped moving anything on an `.ass`
|
|
65
|
+
track, and `StackingSubtitleParser` treated every cue as deliberately placed and left simultaneous
|
|
66
|
+
speakers drawing on top of each other.
|
|
67
|
+
|
|
68
|
+
A new `SsaCueNormalizer` clears the placement Media3 filled in. Recognising it is exact rather
|
|
69
|
+
than a guess: the values are a pure function of the cue's anchors, so a cue matching both defaults
|
|
70
|
+
for the bottom-centre anchors is precisely a cue with no positioning tag. Anything the author did
|
|
71
|
+
place — `\an8`, `\pos`, a style aligned elsewhere — keeps everything and still bypasses stacking.
|
|
72
|
+
|
|
73
|
+
- **Signs keep the colours they were typeset in (Android).** The span filter dropped colour from
|
|
74
|
+
every cue so the app's own subtitle style would win, which is right for dialogue and wrong for a
|
|
75
|
+
sign: typesetting is drawn onto the artwork in colours chosen to sit on it, and the dark lettering
|
|
76
|
+
on the white placards in Jujutsu Kaisen S3E3 became white-on-white. Cues an ASS track positions
|
|
77
|
+
itself now keep their appearance; unpositioned dialogue still loses its own and follows the
|
|
78
|
+
viewer's settings. Only ASS opts in — it is the one format where positioning reliably means
|
|
79
|
+
"sign" rather than a cue setting on ordinary dialogue.
|
|
80
|
+
|
|
81
|
+
- **Typesetters' working notes stay hidden.** A script ships its production notes on a style that
|
|
82
|
+
renders nothing: JJK S3E3 carries `Style: f0ld3r-invisible` with `Fontsize 0` and every colour
|
|
83
|
+
`&HFF……`, holding 158 events of "--mahito", "#1", "check in frame 6212". libass honours both
|
|
84
|
+
signals. Media3 turns a transparent primary colour into a `ForegroundColorSpan` with alpha 0,
|
|
85
|
+
which the span filter then strips, so the note came back in the viewer's colour at the viewer's
|
|
86
|
+
size; the iOS converter never read either field, and WebVTT cannot express opacity anyway. Events
|
|
87
|
+
on such a style are now dropped on both platforms, before anything can make them visible.
|
|
88
|
+
|
|
89
|
+
### Known limitations
|
|
90
|
+
|
|
91
|
+
- Sign **font size** is still the app's, not the script's. Media3 does read `Fontsize` into
|
|
92
|
+
`Cue.textSize`, but the filter clears it so the two subtitle views measure the same glyphs; giving
|
|
93
|
+
it back to signs needs the rounded-background view checked on a device first.
|
|
94
|
+
- Blur, borders, shadows, rotation and `\t` animation are still dropped. Matching libass output
|
|
95
|
+
exactly needs libass, not Media3.
|
|
96
|
+
|
|
10
97
|
## 0.5.0
|
|
11
98
|
|
|
12
99
|
### Added
|
package/FORK.md
CHANGED
|
@@ -27,7 +27,11 @@ Measured against the published `expo-video@56.1.4` tarball. `+`/`-` are added/re
|
|
|
27
27
|
| `android/…/utils/SubtitleSpanFilter.kt` | 135 | Strips colour, size, family and weight from every cue of every format, keeping italic/underline/strikethrough and all positioning, plus `FilteringSubtitleParser` which applies it to whatever a delegate emits. **Load-bearing fact, verified against Media3 1.9.0 bytecode:** `SubtitleViewUtils.removeAllEmbeddedStyling` touches only `clearWindowColor`, `getText`/`setText` and `setTextSize` — never `line`, `position` or `textAlignment`. That is why the filter can live in the parser and the view flag can stay on; if an upstream sync changes it, this whole design has to be revisited. Uses an **allowlist**, because a denylist misses `TextAppearanceSpan` (colour + size + family in one span). `StyleSpan` needs a rewrite rather than a keep/drop: `BOLD_ITALIC` has to come back as plain `ITALIC` over the same range. |
|
|
28
28
|
| `android/…/records/SubtitleSource.kt` | 63 | Record → `MediaItem.SubtitleConfiguration` + MIME resolution. |
|
|
29
29
|
| `android/…/records/SubtitleStyle.kt` | 152 | Record → `CaptionStyleCompat`; hex colour parsing; font-family resolution (`res/font` → `ReactFontManager`); the `applyEmbeddedStyles` flag, which `SubtitleUtils` reads directly rather than `toCaptionStyle` and which defaults to `true` since 0.5.0. |
|
|
30
|
-
| `android/…/utils/StackingSubtitleParser.kt` |
|
|
30
|
+
| `android/…/utils/StackingSubtitleParser.kt` | 342 | `SubtitleParser` that segments the cue timeline and merges simultaneous **unpositioned** cues into one stacked block, plus the `SubtitleParser.Factory` that routes SubRip and ASS/SSA to it and wraps every parser in `FilteringSubtitleParser`. ASS gets there through `SsaCueNormalizer` as the `prepare` step — without it `SsaParser` sets both `line` and `position` on every cue and they would all take the passthrough branch. Assigns each cue a vertical slot it keeps for its whole time on screen — a finished cue's line is held blank (`\u200B`) so the cues above it do not drop. Merges through a `SpannableStringBuilder` so cue spans survive. |
|
|
31
|
+
| `android/…/utils/SsaCueNormalizer.kt` | 93 | Clears the `line`/`position` Media3's `SsaParser` fills in on cues the ASS author never placed, so `bottomOffset` and cue stacking work on `.ass` the way they already do on `.srt`. The test is exact rather than heuristic: those values are a pure function of the cue's anchors, so matching both defaults for the bottom-centre anchors *is* "no positioning tag". Also drops events on a style the author made invisible (`Fontsize 0`, or a primary colour whose inverted alpha byte is `FF`) — that is where typesetters park working notes, and nothing downstream can carry "invisible". |
|
|
32
|
+
| `android/…/utils/AssSignParser.kt` | 505 | Splits an ASS script into the typeset events this package draws and a copy of the script with those lines removed for Media3's parser. One pass, no matching step, so an event is in exactly one of the two outputs and cannot be drawn twice. Pure Kotlin with no Android imports, which is what makes it unit-testable on the JVM. Reads `\pos`, `\move`, `\org`, `\an`, `\frz`, `\frx`, `\fry`, `\fscx/y`, `\fs`, `\c`, `\alpha`, `\b`, `\i`, `\u` and the style columns behind them. The tag scanner takes the longest name first — `\c` must not swallow `\clip`, `\b` must not swallow `\bord`, `\blur` or `\be` — and reads a leading digit as part of the name for `\1c` and `\4a`. |
|
|
33
|
+
| `android/…/utils/AssSignOverlay.kt` | 235 | Draws those signs on a canvas in front of the captions, with `Matrix` for rotation and scale and `Camera` for the two 3-D axes. **Media3 cannot do this**: `SubtitlePainter` has no rotate, skew, Matrix or Camera call, and `Cue` has no rotation field. Sits in the same parent as `SubtitleView` — the content frame, sized to the video, which is why `\pos` survives letterboxing. Lays text out on change rather than per frame, because frame-by-frame typesetting changes the on-screen set every 40 ms. |
|
|
34
|
+
| `android/…/utils/AssSignSpan.kt` | 24 | Carries one parsed sign through Media3's cue stream to the overlay, on a zero-width placeholder so `SubtitleView` draws nothing. Riding the cue stream is what buys the timing for free. |
|
|
31
35
|
| `android/…/utils/RoundedSubtitleBackground.kt` | 352 | Draws `subtitleStyle.backgroundRadius` as rounded boxes, which Media3 cannot do at all: it paints `backgroundColor` as a framework `BackgroundColorSpan` and `windowColor` with `canvas.drawRect`, neither takes a radius, and `SubtitlePainter`/`CanvasSubtitleOutput` are package-private final while `SubtitleView` is final. A second `SubtitleView` is inserted behind `PlayerView`'s own, fed from `Player.Listener.onCues`, drawing a `LineBackgroundSpan` per line. **Its `EDGE_TYPE_NONE` is load-bearing** — `SubtitlePainter` copies the cue text for an edge pass and draws both layouts when the edge type is outline/raised/depressed, which would paint the box twice and darken a semi-transparent colour. State hangs off the view's `tag`, never a map in the companion object; see the `find` KDoc for why even a `WeakHashMap` leaks here. |
|
|
32
36
|
| `FORK.md`, `LICENSE` | — | This guide; MIT licence (upstream copyright preserved). |
|
|
33
37
|
| `eslint.config.js` | 4 | Verbatim copy of `expo-module-scripts/templates/eslint.config.js`. **Recreate it if a sync drops it** — `expo-module configure` treats it as an *optional* template and only syncs it when it already exists, so without this file `npm run lint` dies with "ESLint couldn't find an eslint.config.js". |
|
package/android/build.gradle
CHANGED
|
@@ -26,6 +26,11 @@ dependencies {
|
|
|
26
26
|
implementation "androidx.media3:media3-ui:${androidxMedia3Version}"
|
|
27
27
|
implementation "androidx.media3:media3-datasource-okhttp:${androidxMedia3Version}"
|
|
28
28
|
|
|
29
|
+
// The ASS sign parser is pure Kotlin with no Android imports, which is what makes it worth
|
|
30
|
+
// testing on the JVM: its tag scanner and colour handling are the parts that fail silently on a
|
|
31
|
+
// device, by drawing a sign in the wrong place rather than by crashing.
|
|
32
|
+
testImplementation 'junit:junit:4.13.2'
|
|
33
|
+
|
|
29
34
|
def fragment_version = "1.8.9"
|
|
30
35
|
implementation "androidx.fragment:fragment:$fragment_version"
|
|
31
36
|
implementation "androidx.fragment:fragment-ktx:$fragment_version"
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
package expo.modules.video.utils
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Camera
|
|
6
|
+
import android.graphics.Canvas
|
|
7
|
+
import android.graphics.Color
|
|
8
|
+
import android.graphics.Matrix
|
|
9
|
+
import android.graphics.Paint
|
|
10
|
+
import android.graphics.Typeface
|
|
11
|
+
import android.text.Layout
|
|
12
|
+
import android.text.StaticLayout
|
|
13
|
+
import android.text.TextPaint
|
|
14
|
+
import android.view.View
|
|
15
|
+
import android.view.ViewGroup
|
|
16
|
+
import androidx.annotation.OptIn
|
|
17
|
+
import androidx.media3.common.Player
|
|
18
|
+
import androidx.media3.common.text.Cue
|
|
19
|
+
import androidx.media3.common.text.CueGroup
|
|
20
|
+
import androidx.media3.common.util.UnstableApi
|
|
21
|
+
import androidx.media3.ui.PlayerView
|
|
22
|
+
import androidx.media3.ui.SubtitleView
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Draws ASS typesetting — signs, placards, on-screen labels — with the transforms the script asks
|
|
26
|
+
* for.
|
|
27
|
+
*
|
|
28
|
+
* `SubtitleView` cannot: `SubtitlePainter` has no rotation, and `Cue` has no field to carry one.
|
|
29
|
+
* So [AssSignParser] takes those events out of Media3's cue stream, wraps each in an [AssSignSpan],
|
|
30
|
+
* and this view draws them itself.
|
|
31
|
+
*
|
|
32
|
+
* ### Where it sits
|
|
33
|
+
*
|
|
34
|
+
* As a sibling of `PlayerView`'s own `SubtitleView`, in front of it. That parent is the content
|
|
35
|
+
* frame, which is sized to the *video*, not to the player — which is the whole reason positions
|
|
36
|
+
* survive letterboxing. `\pos` is in `PlayResX`/`PlayResY` space and maps onto this view's bounds
|
|
37
|
+
* directly. [RoundedSubtitleBackground] takes the same route for the same reason.
|
|
38
|
+
*
|
|
39
|
+
* ### Why a canvas and not child views
|
|
40
|
+
*
|
|
41
|
+
* A `TextView` per sign would work — Android view transforms map onto ASS almost one for one — but
|
|
42
|
+
* a heavily typeset episode puts dozens of signs on screen across a scene and each would cost a
|
|
43
|
+
* measure and layout pass. Drawing straight to the canvas costs one `StaticLayout` per sign per
|
|
44
|
+
* frame it changes, and it is the only way to stroke the outline the way ASS means it: the border
|
|
45
|
+
* drawn *behind* the fill, not as a shadow.
|
|
46
|
+
*/
|
|
47
|
+
@SuppressLint("ViewConstructor")
|
|
48
|
+
@OptIn(UnstableApi::class)
|
|
49
|
+
internal class AssSignOverlay(context: Context) : View(context), Player.Listener {
|
|
50
|
+
|
|
51
|
+
private val camera = Camera()
|
|
52
|
+
private val matrix = Matrix()
|
|
53
|
+
|
|
54
|
+
private var signs: List<AssSign> = emptyList()
|
|
55
|
+
private var prepared: List<PreparedSign> = emptyList()
|
|
56
|
+
private var player: Player? = null
|
|
57
|
+
|
|
58
|
+
init {
|
|
59
|
+
setWillNotDraw(false)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override fun onCues(cueGroup: CueGroup) {
|
|
63
|
+
setSigns(cueGroup.cues)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private fun setSigns(cues: List<Cue>) {
|
|
67
|
+
val next = cues.mapNotNull(::signOf)
|
|
68
|
+
// Reference equality on the list contents is enough: the parser produces one AssSign per event
|
|
69
|
+
// and hands out the same instance for every segment the event spans.
|
|
70
|
+
if (next.size == signs.size && next.indices.all { next[it] === signs[it] }) return
|
|
71
|
+
signs = next
|
|
72
|
+
rebuild()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
76
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
77
|
+
// Every measurement below is in view pixels, so all of it is stale after a resize — which
|
|
78
|
+
// happens on every rotation and on entering full screen.
|
|
79
|
+
rebuild()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Lays the signs out once per change rather than once per frame.
|
|
84
|
+
*
|
|
85
|
+
* Frame-by-frame typesetting changes the on-screen set every 40 ms, and `onDraw` can run more
|
|
86
|
+
* often than that for reasons of the view system's own. Text layout is the expensive half of
|
|
87
|
+
* drawing, and none of it depends on anything that varies between frames.
|
|
88
|
+
*/
|
|
89
|
+
private fun rebuild() {
|
|
90
|
+
prepared = if (width == 0 || height == 0) emptyList() else signs.mapNotNull(::prepare)
|
|
91
|
+
invalidate()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fun attachTo(player: Player?) {
|
|
95
|
+
if (this.player === player) return
|
|
96
|
+
this.player?.removeListener(this)
|
|
97
|
+
this.player = player
|
|
98
|
+
player?.addListener(this)
|
|
99
|
+
// A listener only hears about the *next* cue change, so seed from what is on screen already.
|
|
100
|
+
setSigns(player?.currentCues?.cues ?: emptyList())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fun detach() {
|
|
104
|
+
player?.removeListener(this)
|
|
105
|
+
player = null
|
|
106
|
+
signs = emptyList()
|
|
107
|
+
rebuild()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
override fun onDraw(canvas: Canvas) {
|
|
111
|
+
for (sign in prepared) {
|
|
112
|
+
draw(canvas, sign)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun draw(canvas: Canvas, sign: PreparedSign) {
|
|
117
|
+
canvas.save()
|
|
118
|
+
canvas.concat(sign.matrix)
|
|
119
|
+
canvas.translate(sign.left, sign.top)
|
|
120
|
+
sign.stroke?.draw(canvas)
|
|
121
|
+
sign.fill.draw(canvas)
|
|
122
|
+
canvas.restore()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Everything about one sign that does not change between frames. */
|
|
126
|
+
private class PreparedSign(
|
|
127
|
+
val matrix: Matrix,
|
|
128
|
+
val left: Float,
|
|
129
|
+
val top: Float,
|
|
130
|
+
val fill: StaticLayout,
|
|
131
|
+
val stroke: StaticLayout?,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
private fun prepare(sign: AssSign): PreparedSign? {
|
|
135
|
+
// Script space to view space. Both axes are scaled independently so a script authored at a
|
|
136
|
+
// different aspect ratio than the video still lands where it was drawn.
|
|
137
|
+
val scaleToViewX = width / sign.playResX
|
|
138
|
+
val scaleToViewY = height / sign.playResY
|
|
139
|
+
|
|
140
|
+
val alpha = (sign.alpha * 255f).toInt().coerceIn(0, 255)
|
|
141
|
+
if (alpha == 0) return null
|
|
142
|
+
|
|
143
|
+
// A fresh paint per sign: StaticLayout keeps the reference it was built with, so a shared
|
|
144
|
+
// mutable one would repaint every sign already laid out in this pass.
|
|
145
|
+
val fillPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
|
146
|
+
fillPaint.applySign(sign, scaleToViewY)
|
|
147
|
+
fillPaint.color = Color.argb(alpha, Color.red(sign.colour or ALPHA_MASK), Color.green(sign.colour or ALPHA_MASK), Color.blue(sign.colour or ALPHA_MASK))
|
|
148
|
+
|
|
149
|
+
// Wrapping is the script's job, not ours: a sign is placed by hand and its line breaks are
|
|
150
|
+
// written into the text as \N. A width of "as wide as it wants" reproduces that.
|
|
151
|
+
val textWidth = Layout.getDesiredWidth(sign.text, fillPaint).toInt() + 1
|
|
152
|
+
val layout = StaticLayout.Builder
|
|
153
|
+
.obtain(sign.text, 0, sign.text.length, fillPaint, textWidth)
|
|
154
|
+
.setAlignment(Layout.Alignment.ALIGN_CENTER)
|
|
155
|
+
.setIncludePad(false)
|
|
156
|
+
.build()
|
|
157
|
+
|
|
158
|
+
val anchorX = sign.x * scaleToViewX
|
|
159
|
+
val anchorY = sign.y * scaleToViewY
|
|
160
|
+
// \pos names the point the cue's *alignment corner* sits on, so the box is offset from it.
|
|
161
|
+
val left = anchorX - when ((sign.alignment - 1) % 3) {
|
|
162
|
+
0 -> 0f
|
|
163
|
+
2 -> layout.width.toFloat()
|
|
164
|
+
else -> layout.width / 2f
|
|
165
|
+
}
|
|
166
|
+
val top = anchorY - when ((sign.alignment - 1) / 3) {
|
|
167
|
+
0 -> layout.height.toFloat()
|
|
168
|
+
2 -> 0f
|
|
169
|
+
else -> layout.height / 2f
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// \org moves the centre of rotation away from the text, which is how a sign stays glued to a
|
|
173
|
+
// placard that is itself pivoting off-screen. Without it every rotation would spin about the
|
|
174
|
+
// text's own middle and drift off the artwork.
|
|
175
|
+
val pivotX = sign.pivotX * scaleToViewX
|
|
176
|
+
val pivotY = sign.pivotY * scaleToViewY
|
|
177
|
+
|
|
178
|
+
matrix.reset()
|
|
179
|
+
if (sign.rotationX != 0f || sign.rotationY != 0f) {
|
|
180
|
+
camera.save()
|
|
181
|
+
camera.rotateX(sign.rotationX)
|
|
182
|
+
camera.rotateY(sign.rotationY)
|
|
183
|
+
camera.getMatrix(matrix)
|
|
184
|
+
camera.restore()
|
|
185
|
+
// Camera transforms about the origin, so the pivot has to be walked to it and back.
|
|
186
|
+
matrix.preTranslate(-pivotX, -pivotY)
|
|
187
|
+
matrix.postTranslate(pivotX, pivotY)
|
|
188
|
+
}
|
|
189
|
+
matrix.postScale(sign.scaleX, sign.scaleY, pivotX, pivotY)
|
|
190
|
+
matrix.postRotate(sign.rotationZ, pivotX, pivotY)
|
|
191
|
+
|
|
192
|
+
val stroke = if (sign.outlineWidth <= 0f) null else {
|
|
193
|
+
val strokePaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
|
194
|
+
strokePaint.style = Paint.Style.STROKE
|
|
195
|
+
strokePaint.applySign(sign, scaleToViewY)
|
|
196
|
+
strokePaint.strokeWidth = sign.outlineWidth * scaleToViewY * 2f
|
|
197
|
+
strokePaint.color = Color.argb(alpha, Color.red(sign.outlineColour or ALPHA_MASK), Color.green(sign.outlineColour or ALPHA_MASK), Color.blue(sign.outlineColour or ALPHA_MASK))
|
|
198
|
+
// The stroke is centred on the glyph edge, so half of it lands inside the letter. Drawing the
|
|
199
|
+
// fill afterwards covers that half and leaves the outline the width the script asked for.
|
|
200
|
+
StaticLayout.Builder
|
|
201
|
+
.obtain(sign.text, 0, sign.text.length, strokePaint, textWidth)
|
|
202
|
+
.setAlignment(Layout.Alignment.ALIGN_CENTER)
|
|
203
|
+
.setIncludePad(false)
|
|
204
|
+
.build()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return PreparedSign(Matrix(matrix), left, top, layout, stroke)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private fun TextPaint.applySign(sign: AssSign, scaleToViewY: Float) {
|
|
211
|
+
textSize = sign.fontSize * scaleToViewY
|
|
212
|
+
typeface = Typeface.create(
|
|
213
|
+
Typeface.DEFAULT,
|
|
214
|
+
when {
|
|
215
|
+
sign.bold && sign.italic -> Typeface.BOLD_ITALIC
|
|
216
|
+
sign.bold -> Typeface.BOLD
|
|
217
|
+
sign.italic -> Typeface.ITALIC
|
|
218
|
+
else -> Typeface.NORMAL
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
isUnderlineText = sign.underline
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
companion object {
|
|
225
|
+
/** ASS colours are stored without an alpha byte; opacity travels separately. */
|
|
226
|
+
private const val ALPHA_MASK = 0xFF000000.toInt()
|
|
227
|
+
|
|
228
|
+
private fun signOf(cue: Cue): AssSign? {
|
|
229
|
+
val text = cue.text as? android.text.Spanned ?: return null
|
|
230
|
+
return text.getSpans(0, text.length, AssSignSpan::class.java).firstOrNull()?.sign
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Inserts the overlay in front of `PlayerView`'s subtitle view, or returns the one already
|
|
235
|
+
* there.
|
|
236
|
+
*
|
|
237
|
+
* Returns null when the player view has no subtitle view — a configuration this package does
|
|
238
|
+
* not produce, but `PlayerView` allows.
|
|
239
|
+
*/
|
|
240
|
+
/** Removes the overlay from [playerView], dropping its listener registration with it. */
|
|
241
|
+
fun remove(playerView: PlayerView) {
|
|
242
|
+
val parent = playerView.subtitleView?.parent as? ViewGroup ?: return
|
|
243
|
+
for (index in 0 until parent.childCount) {
|
|
244
|
+
val overlay = parent.getChildAt(index) as? AssSignOverlay ?: continue
|
|
245
|
+
overlay.detach()
|
|
246
|
+
parent.removeView(overlay)
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fun install(playerView: PlayerView): AssSignOverlay? {
|
|
252
|
+
val subtitleView: SubtitleView = playerView.subtitleView ?: return null
|
|
253
|
+
val parent = subtitleView.parent as? ViewGroup ?: return null
|
|
254
|
+
|
|
255
|
+
for (index in 0 until parent.childCount) {
|
|
256
|
+
(parent.getChildAt(index) as? AssSignOverlay)?.let { return it }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
val overlay = AssSignOverlay(playerView.context)
|
|
260
|
+
// Directly in front of the captions: a sign belongs to the picture, and the dialogue box
|
|
261
|
+
// should not be able to cover it.
|
|
262
|
+
parent.addView(overlay, parent.indexOfChild(subtitleView) + 1, ViewGroup.LayoutParams(
|
|
263
|
+
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
264
|
+
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
265
|
+
))
|
|
266
|
+
return overlay
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|