feat(subtitles): optionally anchor text subtitles to the screen bottom
Adds an "Anchor to Screen" toggle under Subtitle Styling (Android + ExoPlayer only, default off). When enabled, the text SubtitleView is sized to the full container instead of the letterboxed video rect, so SRT/VTT/mov_text cues render in the black bars below widescreen video and font size and the position setting become relative to the physical screen height. Bitmap (PGS/VOB) and ASS/libass rendering are unchanged; mpv already places plaintext subtitles in the margins by default. close #1730
This commit is contained in:
@@ -215,6 +215,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
@Volatile private var assVideoLatencyFrames = 0
|
||||
private var subtitlePositionPercent: Int = 100
|
||||
private var subtitleFontSize: Float = 55f
|
||||
private var subtitleAnchorToScreen: Boolean = false
|
||||
private var lastSubtitleCues: List<Cue> = emptyList()
|
||||
|
||||
// Tracks whether a text track was selected on the previous onTracksChanged so we
|
||||
@@ -1757,7 +1758,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
videoHeight,
|
||||
pixelRatio,
|
||||
resizeMode,
|
||||
videoZoomScale
|
||||
videoZoomScale,
|
||||
subtitleAnchorToScreen
|
||||
)
|
||||
val bitmapDimensions = SubtitleViewLayout.bitmapDimensions(
|
||||
containerWidth,
|
||||
@@ -3911,7 +3913,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
bgOpacity: Int,
|
||||
subtitlePosition: Int = 100,
|
||||
bold: Boolean = false,
|
||||
italic: Boolean = false
|
||||
italic: Boolean = false,
|
||||
anchorToScreen: Boolean = false
|
||||
) {
|
||||
activity.runOnUiThread {
|
||||
// 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView
|
||||
@@ -3959,6 +3962,18 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
subtitlePositionPercent = clampedPosition
|
||||
subtitleFontSize = fontSize
|
||||
|
||||
// Anchor-to-screen (#1730): resize the text SubtitleView to the full
|
||||
// container so default-placed cues land in the letterbox bars.
|
||||
val anchorChanged = subtitleAnchorToScreen != anchorToScreen
|
||||
subtitleAnchorToScreen = anchorToScreen
|
||||
if (anchorChanged) {
|
||||
lastVideoSize?.let { vs ->
|
||||
if (vs.width > 0 && vs.height > 0) {
|
||||
updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cue-level positioning handles default VTT/SRT placement, whose line
|
||||
// numbers bypass SubtitleView bottom padding. Authored VTT line positions
|
||||
// are preserved in applySubtitlePosition().
|
||||
@@ -3980,7 +3995,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
Log.w(TAG, "Failed to set ASS font scale: ${e.message}")
|
||||
}
|
||||
|
||||
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, assScale=$scale")
|
||||
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, anchorToScreen=$anchorToScreen, assScale=$scale")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1069,6 +1069,7 @@ class ExoPlayerPlugin :
|
||||
val subtitlePosition = call.argument<Number>("subtitlePosition")?.toInt() ?: 100
|
||||
val bold = call.argument<Boolean>("bold") ?: false
|
||||
val italic = call.argument<Boolean>("italic") ?: false
|
||||
val anchorToScreen = call.argument<Boolean>("anchorToScreen") ?: false
|
||||
|
||||
if (usingMpvFallback) {
|
||||
// MPV fallback handles styling via setProperty, no-op here
|
||||
@@ -1076,7 +1077,7 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic)
|
||||
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic, anchorToScreen)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,18 @@ internal object SubtitleViewLayout {
|
||||
videoHeight: Int,
|
||||
pixelRatio: Float,
|
||||
resizeMode: Int,
|
||||
zoomScale: Float
|
||||
zoomScale: Float,
|
||||
anchorToScreen: Boolean = false
|
||||
): SubtitleViewDimensions? {
|
||||
val videoAspect = videoAspect(videoWidth, videoHeight, pixelRatio) ?: return null
|
||||
if (containerWidth <= 0 || containerHeight <= 0) return null
|
||||
|
||||
if (resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT) {
|
||||
// Anchor-to-screen (#1730): size the text view to the full container so
|
||||
// media3's fractional text size and bottom-anchored cue placement compute
|
||||
// against the physical screen, letting subtitles render in the letterbox
|
||||
// bars instead of inside the video rect. Same geometry the non-FIT modes
|
||||
// below already use.
|
||||
if (anchorToScreen || resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT) {
|
||||
return SubtitleViewDimensions(containerWidth, containerHeight)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,49 @@ class SubtitleViewLayoutTest {
|
||||
assertAspectCloseTo16By9(bitmap!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anchorToScreenSizesTextToContainerAndKeepsBitmapOnVideoRect() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, anchorToScreen = true)
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
assertAspectCloseTo16By9(bitmap!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anchorToScreenReachesBelowLetterboxedWideVideo() {
|
||||
// 2.37:1 video on a 16:9 screen — the #1730 use case. Without the anchor
|
||||
// the text view stops at the bottom letterbox bar; with it the view spans
|
||||
// the physical screen height.
|
||||
val unanchored = SubtitleViewLayout.textDimensions(
|
||||
containerWidth = 1920,
|
||||
containerHeight = 1080,
|
||||
videoWidth = 2560,
|
||||
videoHeight = 1080,
|
||||
pixelRatio = 1f,
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT,
|
||||
zoomScale = 1f
|
||||
)
|
||||
val anchored = SubtitleViewLayout.textDimensions(
|
||||
containerWidth = 1920,
|
||||
containerHeight = 1080,
|
||||
videoWidth = 2560,
|
||||
videoHeight = 1080,
|
||||
pixelRatio = 1f,
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT,
|
||||
zoomScale = 1f,
|
||||
anchorToScreen = true
|
||||
)
|
||||
|
||||
assertEquals(SubtitleViewDimensions(1920, 810), unanchored)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), anchored)
|
||||
}
|
||||
|
||||
private fun textDimensions(
|
||||
resizeMode: Int,
|
||||
zoomScale: Float = 1f
|
||||
zoomScale: Float = 1f,
|
||||
anchorToScreen: Boolean = false
|
||||
): SubtitleViewDimensions? = SubtitleViewLayout.textDimensions(
|
||||
containerWidth = 2424,
|
||||
containerHeight = 1080,
|
||||
@@ -55,7 +95,8 @@ class SubtitleViewLayoutTest {
|
||||
videoHeight = 1080,
|
||||
pixelRatio = 1f,
|
||||
resizeMode = resizeMode,
|
||||
zoomScale = zoomScale
|
||||
zoomScale = zoomScale,
|
||||
anchorToScreen = anchorToScreen
|
||||
)
|
||||
|
||||
private fun bitmapDimensions(
|
||||
|
||||
Reference in New Issue
Block a user