From fb805b738bf5603689620fd0199c9039ad91fc3d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:59:11 +0530 Subject: [PATCH] fix ios player rotation via patched libmpv --- .gitignore | 3 + MPVKit | 2 +- .../nuvio/app/features/player/PlayerBridge.kt | 1 + .../app/features/player/PlayerEngine.ios.kt | 24 ++++- iosApp/iosApp/Player/MPVPlayerBridge.swift | 95 ++++++++++++++++++- iosApp/iosApp/Player/MetalLayer.swift | 4 +- 6 files changed, 121 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index c7156d5f7..93f75cabd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ asset scripts/scrape_android_compose_animation_docs.py tools AGENTS.md + +# Local MPVKit iOS build environment (sparse APFS image, see MPVKit docs) +.mpvkit-build.sparseimage diff --git a/MPVKit b/MPVKit index 20afe97c3..be2268fed 160000 --- a/MPVKit +++ b/MPVKit @@ -1 +1 @@ -Subproject commit 20afe97c34e46fce08a3794e2afa64613ea62794 +Subproject commit be2268feded4d8b34aa0631d72df0b70ba9a2fcb diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt index 9715283e2..670c23b1c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt @@ -45,6 +45,7 @@ interface NuvioPlayerBridge { fun setPlaybackSpeed(speed: Float) fun setMuted(muted: Boolean) fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom + fun syncVideoSurfaceLayout(width: Double, height: Double) fun getAudioTrackCount(): Int fun getAudioTrackIndex(at: Int): Int fun getAudioTrackId(at: Int): String diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index e47ecb89c..68697a344 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -18,12 +18,15 @@ import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.Icon import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.dp import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitViewController +import androidx.compose.ui.platform.LocalDensity import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.serialization.encodeToString @@ -58,6 +61,7 @@ actual fun PlatformPlayerSurface( val latestOnControllerReady = rememberUpdatedState(onControllerReady) val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError) + val density = LocalDensity.current PlayerSettingsRepository.ensureLoaded() val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() val latestPlayerSettings = rememberUpdatedState(playerSettings) @@ -341,7 +345,25 @@ actual fun PlatformPlayerSurface( Box(modifier = modifier) { UIKitViewController( factory = { bridge.createPlayerViewController() }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .onSizeChanged { size -> + if (size.width > 1 && size.height > 1) { + bridge.syncVideoSurfaceLayout( + width = with(density) { size.width.toDp().value.toDouble() }, + height = with(density) { size.height.toDp().value.toDouble() }, + ) + } + }, + onResize = { viewController, rect -> + viewController.view.setFrame(rect) + rect.useContents { + bridge.syncVideoSurfaceLayout( + width = size.width, + height = size.height, + ) + } + }, interactive = false, ) diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 6e3e81414..26cac28ab 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -104,6 +104,9 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } func setMuted(muted: Bool) { playerVC?.setMuted(muted) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } + func syncVideoSurfaceLayout(width: Double, height: Double) { + playerVC?.syncVideoSurfaceLayout(size: CGSize(width: width, height: height)) + } // Audio tracks func getAudioTrackCount() -> Int32 { Int32(playerVC?.audioTracks.count ?? 0) } @@ -253,6 +256,8 @@ final class MPVPlayerViewController: UIViewController { private let errorStateLock = NSLock() private var metalLayer = MetalLayer() private var lastAppliedDrawableSize: CGSize = .zero + private var externallyManagedViewSize: CGSize? + private var pendingSurfaceLayoutWorkItems: [DispatchWorkItem] = [] private var pendingLoadRequest: PendingLoadRequest? private var pendingLoadRetryWorkItem: DispatchWorkItem? private var mpv: OpaquePointer? @@ -313,6 +318,8 @@ final class MPVPlayerViewController: UIViewController { metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor metalLayer.wantsExtendedDynamicRangeContent = true + metalLayer.anchorPoint = CGPoint(x: 0, y: 0) + metalLayer.position = .zero view.layer.addSublayer(metalLayer) layoutMetalLayer() @@ -339,19 +346,92 @@ final class MPVPlayerViewController: UIViewController { becomeFirstResponder() UIApplication.shared.beginReceivingRemoteControlEvents() publishCachedNowPlayingInfoIfNeeded() - layoutMetalLayer() + syncVideoSurfaceLayout() attemptStartPendingLoad() } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() - layoutMetalLayer() + syncVideoSurfaceLayout() refreshImmersiveSystemUI() attemptStartPendingLoad() } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + syncVideoSurfaceLayoutNow(scheduleDeferredPasses: false) + coordinator.animate(alongsideTransition: { [weak self] _ in + self?.syncVideoSurfaceLayoutNow(scheduleDeferredPasses: false) + }, completion: { [weak self] _ in + self?.syncVideoSurfaceLayout() + self?.attemptStartPendingLoad() + }) + } + + func syncVideoSurfaceLayout(size: CGSize) { + if Thread.isMainThread { + syncVideoSurfaceLayoutNow(size: size, scheduleDeferredPasses: true) + } else { + DispatchQueue.main.async { [weak self] in + self?.syncVideoSurfaceLayoutNow(size: size, scheduleDeferredPasses: true) + } + } + } + + private func syncVideoSurfaceLayout() { + if Thread.isMainThread { + syncVideoSurfaceLayoutNow(size: nil, scheduleDeferredPasses: true) + } else { + DispatchQueue.main.async { [weak self] in + self?.syncVideoSurfaceLayoutNow(size: nil, scheduleDeferredPasses: true) + } + } + } + + private func syncVideoSurfaceLayoutNow(size: CGSize? = nil, scheduleDeferredPasses: Bool) { + guard isViewLoaded else { return } + if let size, size.width > 1, size.height > 1 { + externallyManagedViewSize = size + applyExternallyManagedViewSize(size) + } + view.setNeedsLayout() + view.layoutIfNeeded() + layoutMetalLayer() + + if scheduleDeferredPasses { + scheduleDeferredSurfaceLayoutPasses() + } + } + + private func scheduleDeferredSurfaceLayoutPasses() { + pendingSurfaceLayoutWorkItems.forEach { $0.cancel() } + pendingSurfaceLayoutWorkItems.removeAll(keepingCapacity: true) + + [0.0, 0.05, 0.15, 0.35].forEach { delay in + let workItem = DispatchWorkItem { [weak self] in + self?.syncVideoSurfaceLayoutNow(scheduleDeferredPasses: false) + } + pendingSurfaceLayoutWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + } + + private func applyExternallyManagedViewSize(_ size: CGSize) { + let targetBounds = CGRect(origin: .zero, size: size) + if view.bounds != targetBounds { + view.bounds = targetBounds + } + + var targetFrame = view.frame + if targetFrame.size != size { + targetFrame.size = size + view.frame = targetFrame + } + } + private func layoutMetalLayer() { - let bounds = view.bounds + let bounds = CGRect(origin: .zero, size: externallyManagedViewSize ?? view.bounds.size) guard bounds.width > 1, bounds.height > 1 else { return } let scale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale @@ -363,8 +443,10 @@ final class MPVPlayerViewController: UIViewController { CATransaction.begin() CATransaction.setDisableActions(true) metalLayer.contentsScale = scale - metalLayer.frame = CGRect(origin: .zero, size: bounds.size) + metalLayer.position = .zero + metalLayer.bounds = CGRect(origin: .zero, size: bounds.size) if drawableSize != lastAppliedDrawableSize { + // mpv's moltenvk context polls drawableSize and resizes its swapchain. metalLayer.drawableSize = drawableSize lastAppliedDrawableSize = drawableSize } @@ -382,7 +464,8 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_request_log_messages(mpv, "warn")) - checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &metalLayer)) + var layerPointer = Int64(Int(bitPattern: Unmanaged.passUnretained(metalLayer).toOpaque())) + checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layerPointer)) checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) @@ -747,6 +830,8 @@ final class MPVPlayerViewController: UIViewController { resignFirstResponder() pendingLoadRetryWorkItem?.cancel() pendingLoadRetryWorkItem = nil + pendingSurfaceLayoutWorkItems.forEach { $0.cancel() } + pendingSurfaceLayoutWorkItems.removeAll(keepingCapacity: false) pendingLoadRequest = nil nowPlayingController.invalidate() clearPlaybackError() diff --git a/iosApp/iosApp/Player/MetalLayer.swift b/iosApp/iosApp/Player/MetalLayer.swift index 27c325ab7..49fd37726 100644 --- a/iosApp/iosApp/Player/MetalLayer.swift +++ b/iosApp/iosApp/Player/MetalLayer.swift @@ -17,7 +17,9 @@ class MetalLayer: CAMetalLayer { if Thread.isMainThread { super.wantsExtendedDynamicRangeContent = newValue } else { - DispatchQueue.main.sync { + // mpv's vo thread sets this during video-output init while it holds the + // core lock; a sync hop here deadlocks against main-thread property reads. + DispatchQueue.main.async { super.wantsExtendedDynamicRangeContent = newValue } }