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);