From a0269c6febe04763255ecc2761d3ed5dbeb48c99 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:17:40 +0200 Subject: [PATCH] fix(player): keep HDR/Dolby Vision through zoom on iOS and tvOS Nonzero mpv video-zoom flips vo_avfoundation into a per-frame Core Image re-render that destroys HDR/DV passthrough - DV frames render near-black on tvOS (verified on Apple TV 4K, DV P7->8.1 content: panel luma mean 0.0 zoomed vs 87-103 unzoomed at locked exposure). Zoom now scales the AVSampleBufferDisplayLayer itself (a sublayerTransform on the container is ignored by the video plane) via the existing Player.setVideoZoom seam, and VideoFilterManager pins the mpv property to 0 on backends with native zoom. The layer tree at 100% stays identical to before: clipping engages only while zoomed, and updateFrame sizes the layer via bounds/position, which frame= decomposes to anyway. macOS keeps the property path (gpu-next zooms losslessly in-shader); Android is untouched. --- ios/Runner/MpvPlayer/MpvPlayerCore.swift | 44 ++++++++++++++++++-- ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 13 ++++++ lib/mpv/player/player.dart | 8 +++- lib/mpv/player/player_base.dart | 2 +- lib/mpv/player/player_native.dart | 11 +++++ lib/screens/video_player/parts/pip.dart | 3 ++ lib/services/video_filter_manager.dart | 9 +++- test/services/video_filter_manager_test.dart | 23 ++++++++++ 8 files changed, 106 insertions(+), 7 deletions(-) diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index d7b3f52c..9bad5eb9 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -86,14 +86,12 @@ class MpvPlayerCore: MpvPlayerCoreBase { withoutLayerAnimations { if let frame { containerView.frame = frame - videoLayer.frame = containerView.bounds } else if let superview = containerView.superview { containerView.frame = superview.bounds - videoLayer.frame = containerView.bounds } else if let window { containerView.frame = window.bounds - videoLayer.frame = containerView.bounds } + fitVideoLayer(videoLayer, in: containerView) mainBlankView?.frame = window?.bounds ?? .zero @@ -107,6 +105,46 @@ class MpvPlayerCore: MpvPlayerCoreBase { #endif } + /// Size the video layer to the container via bounds/position, never `frame`: + /// `frame` is undefined while the custom-zoom transform is non-identity. + private func fitVideoLayer(_ layer: MpvVideoLayer, in container: UIView) { + layer.bounds = CGRect(origin: .zero, size: container.bounds.size) + layer.position = CGPoint(x: container.bounds.midX, y: container.bounds.midY) + } + + /// Apply custom viewer zoom by scaling the video layer about its center. + /// + /// Zoom must never reach mpv's `video-zoom` on this VO: any nonzero zoom + /// flips vo_avfoundation into a Core Image re-render of every frame, which + /// destroys HDR/Dolby Vision passthrough (DV frames render near-black on + /// tvOS). A CALayer transform keeps the sample-buffer scanout path intact. + /// The transform must sit on the display layer itself — a + /// `sublayerTransform` on the container is ignored by the video plane. + /// The layer's bounds never change, so the VO's bounds KVO stays quiet, and + /// the inline OSD sibling layer keeps its own geometry. PiP is unaffected: + /// the Dart side resets zoom before entry, and the system presents the + /// layer's buffers, not its on-screen transform. + func setVideoZoom(_ scale: Double) { + let clamped = min(max(scale, 0.25), 4.0) + Self.log("setVideoZoom(\(scale)) -> \(clamped)") + DispatchQueue.main.async { [weak self] in + guard let self, let container = self.containerView, let videoLayer = self.videoLayer else { + return + } + let zoomed = abs(clamped - 1.0) >= 0.0005 + self.withoutLayerAnimations { + // Clip only while zoomed: at 100% the layer tree stays identical to a + // build without custom zoom, so the unzoomed path cannot regress + // (e.g. hardware-plane promotion of the sample-buffer layer). + container.clipsToBounds = zoomed + videoLayer.transform = + zoomed + ? CATransform3DMakeScale(CGFloat(clamped), CGFloat(clamped), 1) + : CATransform3DIdentity + } + } + } + func externalDisplayDidChange() { refreshExternalDisplayAttachment() } diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index 58e05aac..566cd587 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -91,6 +91,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS handleSetDisplayCriteria(call: call, result: result) case "setVisible": handleSetVisible(call: call, result: result) + case "setVideoZoom": + handleSetVideoZoom(call: call, result: result) case "isInitialized": result(playerCore?.isInitialized ?? false) case "updateFrame": @@ -451,6 +453,17 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS } } + private func handleSetVideoZoom(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let scale = doubleValue(args["scale"]) + else { + result(FlutterError(code: "INVALID_ARGS", message: "setVideoZoom requires scale", details: nil)) + return + } + playerCore?.setVideoZoom(scale) + result(nil) + } + private func int64Value(_ value: Any?) -> Int64? { switch value { case let value as Int64: diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index a4cc714d..6ae82a48 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -326,8 +326,12 @@ abstract class Player { /// here and scale via `panscan`/`video-aspect-override` properties instead. Future setBoxFitMode(int mode); - /// Apply custom zoom to the native video layer. No-op on mpv backends, - /// which zoom via the `video-zoom` property. + /// Apply custom zoom to the native video layer. + /// + /// ExoPlayer scales its frame layout; iOS/tvOS scale the AVFoundation video + /// container (mpv's `video-zoom` would force vo_avfoundation's Core Image + /// path and destroy HDR/Dolby Vision passthrough). Other mpv backends are a + /// no-op here and zoom via the `video-zoom` property. Future setVideoZoom(double scale); /// Aggregated native playback stats (codecs, dimensions, dropped frames…). diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index c64bade0..e3adfb20 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -1100,7 +1100,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { Future setBoxFitMode(int mode) async {} @override - // ignore: no-empty-block - base no-op, mpv zooms via the video-zoom property + // ignore: no-empty-block - base no-op, non-Apple mpv zooms via the video-zoom property Future setVideoZoom(double scale) async {} @override diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 4421517b..b802cfad 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -1013,6 +1013,17 @@ class PlayerNative extends PlayerBase { } } + /// iOS/tvOS scale the native video container instead of mpv's `video-zoom`: + /// on the avfoundation VO a nonzero zoom re-renders every frame through + /// Core Image, which destroys HDR/Dolby Vision passthrough (DV renders + /// near-black on tvOS). macOS keeps the property path — gpu-next zooms + /// losslessly in-shader. + @override + Future setVideoZoom(double scale) async { + if (_nativeCoreUnavailable || audioOnly || !Platform.isIOS || !initialized) return; + await invoke('setVideoZoom', {'scale': scale}); + } + @override Future setVideoFrameRate( double fps, diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index f52a1dfc..076b475d 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -37,6 +37,9 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { if (needsVideoFilter && _videoFilterManager == null && settings != null) { _videoFilterManager = VideoFilterManager( player: currentPlayer, + // iOS and tvOS zoom the native video layer; mpv's video-zoom would + // force vo_avfoundation's Core Image path and kill HDR/DV passthrough. + nativeVideoZoom: Platform.isIOS, initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode), initialPlayerSize: initialPlayerSize, onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode), diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index dc9237b9..e62f5747 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -46,6 +46,12 @@ class VideoFilterManager { final Player player; + /// Whether [Player.setVideoZoom] scales the native video layer itself, so + /// the mpv `video-zoom` property must stay 0. On iOS/tvOS the avfoundation + /// VO re-renders zoomed frames through Core Image, which destroys HDR and + /// Dolby Vision passthrough — zoom must stay out of mpv's pipeline there. + final bool nativeVideoZoom; + /// BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) int _boxFitMode; @@ -85,6 +91,7 @@ class VideoFilterManager { VideoFilterManager({ required this.player, + this.nativeVideoZoom = false, int initialBoxFitMode = 0, Size? initialPlayerSize, this.onBoxFitModeChanged, @@ -268,7 +275,7 @@ class VideoFilterManager { } await _applyProperty('sub-ass-force-margins', coverMode || zoomScale > 1.0001 ? 'yes' : 'no'); await _applyProperty('panscan', coverMode ? '1.0' : '0'); - await _applyProperty('video-zoom', videoZoomPropertyForScale(zoomScale).toString()); + await _applyProperty('video-zoom', nativeVideoZoom ? '0.0' : videoZoomPropertyForScale(zoomScale).toString()); } catch (e) { appLogger.w('Failed to update video filter', error: e); } diff --git a/test/services/video_filter_manager_test.dart b/test/services/video_filter_manager_test.dart index 2b67e75a..770f7021 100644 --- a/test/services/video_filter_manager_test.dart +++ b/test/services/video_filter_manager_test.dart @@ -77,6 +77,29 @@ void main() { expect(player.writes.single.value, VideoFilterManager.videoZoomPropertyForScale(1.5).toString()); }); + test('native zoom keeps mpv video-zoom at zero and forwards the scale', () async { + final player = _RecordingPlayer(); + final manager = VideoFilterManager(player: player, nativeVideoZoom: true); + addTearDown(manager.dispose); + + await manager.updateVideoFilter(); + player.clearRecords(); + + manager.setZoomScale(1.5); + await Future.delayed(Duration.zero); + + // The native layer gets the real scale; mpv must never see a nonzero + // video-zoom — on vo_avfoundation it would trigger the Core Image + // re-render that destroys HDR/Dolby Vision passthrough. + expect(player.zoomCalls, [1.5]); + expect(player.writes.where((write) => write.key == 'video-zoom'), isEmpty); + + // Margin forcing still follows the zoom state. + final marginWrites = player.writes.where((write) => write.key == 'sub-ass-force-margins').toList(); + expect(marginWrites, hasLength(1)); + expect(marginWrites.single.value, 'yes'); + }); + test('repeated run with unchanged state writes nothing', () async { final player = _RecordingPlayer(); final manager = VideoFilterManager(player: player);