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(
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Formatlaşdırmanı sil",
"positionTop": "Yuxarı",
"positionBottom": "Aşağı",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Qalın",
"italic": "Kursiv",
"renderResolution": "Emal imkanı (Resolution)",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Премахване на стиловете",
"positionTop": "Горе",
"positionBottom": "Долу",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Получер",
"italic": "Курсив",
"renderResolution": "Резолюция на изобразяване",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Fjern formatering",
"positionTop": "Øverst",
"positionBottom": "Nederst",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Fed",
"italic": "Kursiv",
"renderResolution": "Gengivelsesopløsning",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Formatierung entfernen",
"positionTop": "Oben",
"positionBottom": "Unten",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Fett",
"italic": "Kursiv",
"renderResolution": "Render-Auflösung",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Remove styling",
"positionTop": "Top",
"positionBottom": "Bottom",
"anchorToScreen": "Anchor to Screen",
"anchorToScreenDescription": "Show text subtitles in the black bars below widescreen video",
"bold": "Bold",
"italic": "Italic",
"renderResolution": "Render Resolution",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Quitar estilos",
"positionTop": "Arriba",
"positionBottom": "Abajo",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Negrita",
"italic": "Cursiva",
"renderResolution": "Resolución de renderizado",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Supprimer le style",
"positionTop": "Haut",
"positionBottom": "Bas",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Gras",
"italic": "Italique",
"renderResolution": "Résolution de rendu",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Stílus eltávolítása",
"positionTop": "Fent",
"positionBottom": "Lent",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Félkövér",
"italic": "Dőlt",
"renderResolution": "Renderelési felbontás",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Rimuovi stile",
"positionTop": "In alto",
"positionBottom": "In basso",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Grassetto",
"italic": "Corsivo",
"renderResolution": "Risoluzione di rendering",
+2
View File
@@ -740,6 +740,8 @@
"overrideStrip": "スタイルを削除",
"positionTop": "上",
"positionBottom": "下",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "太字",
"italic": "斜体",
"renderResolution": "レンダリング解像度",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Форматтауды жою",
"positionTop": "Жоғары",
"positionBottom": "Төмен",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Қалың",
"italic": "Көлбеу",
"renderResolution": "Рендеринг ажыратымдылығы",
+2
View File
@@ -740,6 +740,8 @@
"overrideStrip": "스타일 제거",
"positionTop": "위",
"positionBottom": "아래",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "굵게",
"italic": "기울임꼴",
"renderResolution": "렌더링 해상도",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Fjern formatering",
"positionTop": "Øverst",
"positionBottom": "Nederst",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Fet",
"italic": "Kursiv",
"renderResolution": "Gjengivelsesoppløsning",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Opmaak verwijderen",
"positionTop": "Bovenaan",
"positionBottom": "Onderaan",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Vet",
"italic": "Cursief",
"renderResolution": "Renderresolutie",
+2
View File
@@ -752,6 +752,8 @@
"overrideStrip": "Usuń style",
"positionTop": "Góra",
"positionBottom": "Dół",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Pogrubienie",
"italic": "Kursywa",
"renderResolution": "Rozdzielczość renderowania",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Remover estilo",
"positionTop": "Superior",
"positionBottom": "Inferior",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Negrito",
"italic": "Itálico",
"renderResolution": "Resolução de renderização",
+2
View File
@@ -752,6 +752,8 @@
"overrideStrip": "Удалить стили",
"positionTop": "Сверху",
"positionBottom": "Снизу",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Жирный",
"italic": "Курсив",
"renderResolution": "Разрешение отрисовки",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 38352 (1743 per locale)
/// Strings: 38354 (1743 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+12 -4
View File
@@ -2212,6 +2212,12 @@ class Translations$subtitlingStyling$en {
/// en: 'Bottom'
String get positionBottom => 'Bottom';
/// en: 'Anchor to Screen'
String get anchorToScreen => 'Anchor to Screen';
/// en: 'Show text subtitles in the black bars below widescreen video'
String get anchorToScreenDescription => 'Show text subtitles in the black bars below widescreen video';
/// en: 'Bold'
String get bold => 'Bold';
@@ -6762,6 +6768,8 @@ extension on Translations {
'subtitlingStyling.overrideStrip' => 'Remove styling',
'subtitlingStyling.positionTop' => 'Top',
'subtitlingStyling.positionBottom' => 'Bottom',
'subtitlingStyling.anchorToScreen' => 'Anchor to Screen',
'subtitlingStyling.anchorToScreenDescription' => 'Show text subtitles in the black bars below widescreen video',
'subtitlingStyling.bold' => 'Bold',
'subtitlingStyling.italic' => 'Italic',
'subtitlingStyling.renderResolution' => 'Render Resolution',
@@ -7091,10 +7099,10 @@ extension on Translations {
'explore.stats.favorited' => ({required Object n}) => '${n} favorites',
'explore.stats.dropRate' => ({required Object percent}) => '${percent} dropped it',
'explore.stats.comments' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} comment', other: '${n} comments', ),
'explore.stats.votes' => ({required Object n}) => '${n} votes',
'explore.stats.watching' => ({required Object n}) => '${n} watching it',
_ => null,
} ?? switch (path) {
'explore.stats.votes' => ({required Object n}) => '${n} votes',
'explore.stats.watching' => ({required Object n}) => '${n} watching it',
'explore.stats.completed' => ({required Object n}) => '${n} completed',
'explore.stats.onHold' => ({required Object n}) => '${n} on hold',
'explore.stats.dropped' => ({required Object n}) => '${n} dropped',
@@ -7605,10 +7613,10 @@ extension on Translations {
'externalPlayer.useExternalPlayerDescription' => 'Open videos in another app',
'externalPlayer.selectPlayer' => 'Select Player',
'externalPlayer.customPlayers' => 'Custom Players',
'externalPlayer.systemDefault' => 'System Default',
'externalPlayer.addCustomPlayer' => 'Add Custom Player',
_ => null,
} ?? switch (path) {
'externalPlayer.systemDefault' => 'System Default',
'externalPlayer.addCustomPlayer' => 'Add Custom Player',
'externalPlayer.playerName' => 'Player Name',
'externalPlayer.playerNameHint' => 'My Player',
'externalPlayer.playerCommand' => 'Command',
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Ta bort formatering",
"positionTop": "Överst",
"positionBottom": "Nederst",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Fet",
"italic": "Kursiv",
"renderResolution": "Renderingsupplösning",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Biçimlendirmeyi kaldır",
"positionTop": "Üst",
"positionBottom": "Alt",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Kalın",
"italic": "İtalik",
"renderResolution": "İşleme Çözünürlüğü",
+2
View File
@@ -744,6 +744,8 @@
"overrideStrip": "Formatlashni olib tashlash",
"positionTop": "Yuqori",
"positionBottom": "Pastki",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Qalin",
"italic": "Qiya",
"renderResolution": "Renderlash oʻlchamlari",
+2
View File
@@ -740,6 +740,8 @@
"overrideStrip": "移除樣式",
"positionTop": "頂部",
"positionBottom": "底部",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "粗體",
"italic": "斜體",
"renderResolution": "渲染解析度",
+2
View File
@@ -740,6 +740,8 @@
"overrideStrip": "移除样式",
"positionTop": "顶部",
"positionBottom": "底部",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "粗体",
"italic": "斜体",
"renderResolution": "渲染分辨率",
@@ -542,6 +542,7 @@ class PlayerAndroid extends PlayerBase {
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
bool anchorToScreen = false,
}) async {
if (disposed || !initialized) return;
await invoke('setSubtitleStyle', {
@@ -554,6 +555,7 @@ class PlayerAndroid extends PlayerBase {
'subtitlePosition': subtitlePosition,
'bold': bold,
'italic': italic,
'anchorToScreen': anchorToScreen,
});
}
+1
View File
@@ -314,6 +314,7 @@ abstract class Player {
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
bool anchorToScreen = false,
});
/// Apply the box-fit mode to the native video layer
+1
View File
@@ -1097,6 +1097,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
bool anchorToScreen = false,
}) async {}
@override
@@ -42,6 +42,9 @@ class SubtitleStylingScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Anchor-to-screen is an ExoPlayer text-subtitle knob; mpv already places
// plaintext subtitles in the letterbox margins by default.
final exoActive = Platform.isAndroid && SettingsService.instance.read(SettingsService.useExoPlayer);
return SettingsPage(
title: Text(t.screens.subtitleStyling),
children: [
@@ -108,6 +111,13 @@ class SubtitleStylingScreen extends StatelessWidget {
min: 0,
max: 100,
),
if (exoActive)
SettingSwitchTile(
pref: SettingsService.subtitleAnchorToScreen,
icon: Symbols.fit_screen_rounded,
title: t.subtitlingStyling.anchorToScreen,
subtitle: t.subtitlingStyling.anchorToScreenDescription,
),
SettingSwitchTile(
pref: SettingsService.subtitleBold,
icon: Symbols.format_bold_rounded,
@@ -432,6 +432,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
subtitlePosition: settingsService.read(SettingsService.subtitlePosition),
bold: settingsService.read(SettingsService.subtitleBold),
italic: settingsService.read(SettingsService.subtitleItalic),
anchorToScreen: settingsService.read(SettingsService.subtitleAnchorToScreen),
);
}
+7
View File
@@ -377,6 +377,12 @@ class SettingsService extends BaseSharedPreferencesService {
);
static const subtitleBold = BoolPref('subtitle_bold');
static const subtitleItalic = BoolPref('subtitle_italic');
/// Render text subtitles (SRT/VTT/mov_text) anchored to the physical screen
/// instead of the video rect, so they land in the letterbox bars of
/// widescreen video (#1730). ExoPlayer backend only; mpv already places
/// plaintext subtitles in the margins by default (sub-use-margins=yes).
static const subtitleAnchorToScreen = BoolPref('subtitle_anchor_to_screen');
static const cleanedOldImageCache = BoolPref('cleaned_old_image_cache');
static const rememberTrackSelections = BoolPref('remember_track_selections', defaultValue: true);
@@ -935,6 +941,7 @@ class SettingsService extends BaseSharedPreferencesService {
maxVolume,
downmixCenterBoost,
subtitlePosition,
subtitleAnchorToScreen,
defaultPlaybackSpeed,
defaultBoxFitMode,
themeMode,
@@ -368,6 +368,7 @@ class FakePlayer implements Player {
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
bool anchorToScreen = false,
}) async {}
@override