diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index c2e7106a..0bdd82d3 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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(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(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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index e0637b21..22ae6378 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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("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("name") val value = call.argument("value") diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 1c19a148..b166b60b 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -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 setBoxFitMode(int mode) async { + if (disposed || !initialized) return; + await invoke('setBoxFitMode', {'mode': mode}); + } + // ============================================ // Frame Rate Matching // ============================================ diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 8c15048d..839282ad 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -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'); } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index f723c177..84821cc7 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1157,7 +1157,7 @@ class _PlexVideoControlsState extends State 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,