feat(exoplayer): box-fit modes (letterbox/cover/stretch)

This commit is contained in:
edde746
2026-04-21 18:17:25 +02:00
parent 7c331e51e6
commit 839465b7f2
5 changed files with 131 additions and 23 deletions
@@ -50,6 +50,7 @@ import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import androidx.media3.extractor.mp4.Mp4Extractor
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.SubtitleView
import com.edde746.plezy.shared.AudioFocusManager
@@ -108,6 +109,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var surfaceView: SurfaceView? = null
private var surfaceContainer: FrameLayout? = null
private var videoAspectContainer: AspectRatioFrameLayout? = null
private var subtitleView: SubtitleView? = null
private var assHandler: AssHandler? = null
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
@@ -268,29 +270,42 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
log = { emitLog("info", "framerate", it) }
)
// Create FrameLayout container for video (enables centering for aspect ratio)
// Create FrameLayout container for video (clips overflow for ZOOM crop mode)
surfaceContainer = FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(Color.BLACK)
clipChildren = true
}
// Create SurfaceView for video rendering
surfaceView = SurfaceView(activity).apply {
// AspectRatioFrameLayout drives FIT/ZOOM/FILL via Media3's resizeMode.
// Centered inside the container; in ZOOM mode it measures larger than
// the container and the parent's clipChildren crops the overflow.
videoAspectContainer = AspectRatioFrameLayout(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
).apply {
gravity = Gravity.CENTER
}
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
// Create SurfaceView for video rendering (fills the ARFL)
surfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(surfaceCallback)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
}
surfaceContainer!!.addView(surfaceView)
videoAspectContainer!!.addView(surfaceView)
surfaceContainer!!.addView(videoAspectContainer)
// Create SubtitleView - added to surfaceContainer above video
// With OVERLAY_OPEN_GL mode, libass-android adds AssSubtitleTextureView as a child
@@ -817,36 +832,61 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (disposing) return
if (videoWidth == 0 || videoHeight == 0) return
val surface = surfaceView ?: return
val subtitle = subtitleView
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
val videoAspect = (videoWidth * pixelRatio) / videoHeight
activity.runOnUiThread {
videoAspectContainer?.setAspectRatio(videoAspect)
}
updateSubtitleViewSize(videoWidth, videoHeight, pixelRatio)
}
private fun updateSubtitleViewSize(videoWidth: Int, videoHeight: Int, pixelRatio: Float) {
if (disposing) return
if (videoWidth == 0 || videoHeight == 0) return
val subtitle = subtitleView ?: return
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
val containerWidth = contentView.width
val containerHeight = contentView.height
if (containerWidth == 0 || containerHeight == 0) return
// Calculate video aspect ratio (accounting for non-square pixels)
val videoAspect = (videoWidth * pixelRatio) / videoHeight
val containerAspect = containerWidth.toFloat() / containerHeight
val (newWidth, newHeight) = if (videoAspect > containerAspect) {
// Video is wider - fit to width, letterbox top/bottom
containerWidth to (containerWidth / videoAspect).toInt()
// In cover/stretch modes subtitles stay at container size so they never get
// cropped or distorted. In letterbox mode they follow the video rect so they
// anchor to the bottom of the video (matching MPV's default sub positioning).
val isLetterbox = videoAspectContainer?.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT
val (subWidth, subHeight) = if (isLetterbox) {
val videoAspect = (videoWidth * pixelRatio) / videoHeight
val containerAspect = containerWidth.toFloat() / containerHeight
if (videoAspect > containerAspect) {
containerWidth to (containerWidth / videoAspect).toInt()
} else {
(containerHeight * videoAspect).toInt() to containerHeight
}
} else {
// Video is taller - fit to height, pillarbox left/right
(containerHeight * videoAspect).toInt() to containerHeight
containerWidth to containerHeight
}
activity.runOnUiThread {
surface.layoutParams = FrameLayout.LayoutParams(newWidth, newHeight).apply {
subtitle.layoutParams = FrameLayout.LayoutParams(subWidth, subHeight).apply {
gravity = Gravity.CENTER
}
surface.requestLayout()
subtitle?.let { sv ->
sv.layoutParams = FrameLayout.LayoutParams(newWidth, newHeight).apply {
gravity = Gravity.CENTER
subtitle.requestLayout()
}
}
private fun boxFitModeToResizeMode(mode: Int): Int = when (mode) {
1 -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
2 -> AspectRatioFrameLayout.RESIZE_MODE_FILL
else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
}
fun setBoxFitMode(mode: Int) {
if (disposing) return
activity.runOnUiThread {
videoAspectContainer?.resizeMode = boxFitModeToResizeMode(mode.coerceIn(0, 2))
lastVideoSize?.let { vs ->
if (vs.width > 0 && vs.height > 0) {
updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
}
sv.requestLayout()
}
}
}
@@ -1791,6 +1831,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Synchronous ownership invalidation — stale code can no longer
// reach surface state through instance fields.
surfaceContainer = null
videoAspectContainer = null
surfaceView = null
subtitleView = null
@@ -140,6 +140,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
result.success(am?.largeMemoryClass ?: 0)
}
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"setMpvProperty" -> handleSetMpvProperty(call, result)
"setLogLevel" -> {
@@ -512,6 +513,55 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
result.success(null)
}
private fun handleSetBoxFitMode(call: MethodCall, result: MethodChannel.Result) {
val mode = call.argument<Number>("mode")?.toInt()
if (mode == null) {
result.error("INVALID_ARGS", "Missing 'mode'", null)
return
}
// Translate mode to MPV properties so the fallback path (and any
// future fallback from this session) stays in sync with the UI state.
// Mirrors VideoFilterManager.updateVideoFilter's MPV branch.
val mpvProps = when (mode) {
1 -> listOf(
"video-aspect-override" to "no",
"panscan" to "1.0",
"sub-ass-force-margins" to "yes",
)
2 -> {
val act = activity
val aspect = if (act != null) {
val dm = act.resources.displayMetrics
if (dm.heightPixels > 0) dm.widthPixels.toFloat() / dm.heightPixels else 0f
} else 0f
listOf(
"video-aspect-override" to (if (aspect > 0) aspect.toString() else "no"),
"panscan" to "0",
"sub-ass-force-margins" to "no",
)
}
else -> listOf(
"video-aspect-override" to "no",
"panscan" to "0",
"sub-ass-force-margins" to "no",
)
}
val keys = mpvProps.map { it.first }.toSet()
pendingMpvProperties.removeAll { it.first in keys }
pendingMpvProperties.addAll(mpvProps)
if (usingMpvFallback) {
mpvProps.forEach { (k, v) -> mpvCore?.setProperty(k, v) }
result.success(null)
return
}
activity?.runOnUiThread {
playerCore?.setBoxFitMode(mode)
result.success(null)
} ?: result.success(null)
}
private fun handleSetMpvProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")
@@ -371,6 +371,17 @@ class PlayerAndroid extends PlayerBase {
});
}
// ============================================
// Box-fit / video scaling mode
// ============================================
/// Apply the box-fit mode to the native ExoPlayer layer.
/// Maps to AspectRatioFrameLayout resize mode: 0=FIT, 1=ZOOM, 2=FILL.
Future<void> setBoxFitMode(int mode) async {
if (disposed || !initialized) return;
await invoke('setBoxFitMode', {'mode': mode});
}
// ============================================
// Frame Rate Matching
// ============================================
+6
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:rate_limiter/rate_limiter.dart';
import '../mpv/mpv.dart';
import '../mpv/player/platform/player_android.dart';
import '../models/plex_media_version.dart';
import '../utils/app_logger.dart';
@@ -133,6 +134,11 @@ class VideoFilterManager {
/// When ambient lighting is active, video-aspect-override is managed by ambient lighting.
void updateVideoFilter() async {
try {
if (player.playerType == 'exoplayer') {
await (player as PlayerAndroid).setBoxFitMode(_boxFitMode);
return;
}
if (ambientLightingService?.isEnabled != true) {
await player.setProperty('video-aspect-override', 'no');
}
@@ -1157,7 +1157,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
isFullscreen: _isFullscreen,
isAlwaysOnTop: _isAlwaysOnTop,
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null,
onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onToggleRotationLock: _toggleRotationLock,
onToggleScreenLock: _toggleScreenLock,
onToggleFullscreen: _toggleFullscreen,