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:
edde746
2026-08-09 17:14:34 +02:00
parent 8ad13e94cf
commit 8e5279a487
35 changed files with 151 additions and 13 deletions
@@ -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(