feat(exoplayer): box-fit modes (letterbox/cover/stretch)
This commit is contained in:
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user