From 8d615579e2dbb60241ee91d52c440bc83fcc455b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 14 Oct 2026 18:01:22 +0200 Subject: [PATCH] fix(ios): fit the picture to the window after a rotation MPVKit's MoltenVK context reads the layer's drawableSize only when mpv builds its video output and reports no resize after that (mpvkit/MPVKit#3). A player opened upright and turned on its side went on drawing the upright frame into a corner of the landscape drawable. The view now tells the player when its drawable changes, and once the size settles the player rebuilds mpv's output by turning the picture off and on, as the background already does. The vid writes run in order off the main thread. --- ios/ProtonStream/Player/MpvPlayer.swift | 54 +++++++++++++++++++++++- ios/ProtonStream/Player/PlayerView.swift | 10 +++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/ios/ProtonStream/Player/MpvPlayer.swift b/ios/ProtonStream/Player/MpvPlayer.swift index 8e61213..93a44df 100644 --- a/ios/ProtonStream/Player/MpvPlayer.swift +++ b/ios/ProtonStream/Player/MpvPlayer.swift @@ -104,6 +104,17 @@ final class MpvPlayer { /// Subtitle files handed over before their file loaded, and whether each /// is the one to turn on. @ObservationIgnored private var pendingSubtitles: [(SubtitleFileRecord, Bool)] = [] + /// The drawable size mpv last built its output for; see `outputResized`. + @ObservationIgnored private var outputSize: CGSize = .zero + /// Off while the app is in the background, where a refit must not bring + /// the picture back. + @ObservationIgnored private var videoEnabled = true + @ObservationIgnored private var refitTask: Task? + /// Every `vid` write, in the order they were made. Each one tears down or + /// builds mpv's video output and waits on its render thread, which the + /// main thread must not; and a refit's "auto" must never land after the + /// background's "no". + @ObservationIgnored private let videoQueue = DispatchQueue(label: "mpv-video") /// A core, or nil when libmpv would not start. static func create() -> MpvPlayer? { @@ -291,7 +302,42 @@ final class MpvPlayer { /// Showing no picture while backgrounded keeps the audio playing: iOS /// refuses GPU work from a background app, and would end the process for it. - func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") } + func setVideoEnabled(_ enabled: Bool) { + videoEnabled = enabled + setVideo([enabled ? "auto" : "no"]) + } + + /// The layer's drawable changed size: a rotation, a Split View resize. + /// + /// MPVKit's MoltenVK context reads `drawableSize` only when mpv builds its + /// output and never hears of a resize after that (mpvkit/MPVKit#3), so mpv + /// went on drawing a frame of the old size into the corner of the new + /// drawable. Turning the picture off and on builds the output again at the + /// new size — the same round trip the background makes, at the cost of a + /// refresh seek. Once the size has settled, so a Split View drag rebuilds + /// it once rather than per step. + func outputResized() { + refitTask?.cancel() + refitTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled, let self else { return } + let size = layer.drawableSize + // Only an output that exists and is the wrong size: nothing to fit + // while a file loads, and the background's "no" stays. + guard videoEnabled, videoWidth > 0, outputSize != .zero, size != outputSize, + size.width > 1, size.height > 1 else { return } + outputSize = size + setVideo(["no", "auto"]) + } + } + + private func setVideo(_ values: [String]) { + guard let mpv else { return } + let core = UncheckedCore(mpv) + videoQueue.async { + for value in values { mpv_set_property_string(core.pointer, "vid", value) } + } + } /// Stop the open file. The stream behind it is released by mpv closing it, /// which this causes; releasing it from here would race that close. @@ -314,6 +360,7 @@ final class MpvPlayer { case loaded case ended(Int64) case restarted + case reconfigured case log(prefix: String, text: String) } @@ -345,6 +392,8 @@ final class MpvPlayer { decoded = .loaded case MPV_EVENT_PLAYBACK_RESTART: decoded = .restarted + case MPV_EVENT_VIDEO_RECONFIG: + decoded = .reconfigured case MPV_EVENT_END_FILE: if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee { decoded = .ended(Int64(end.reason.rawValue)) @@ -427,6 +476,9 @@ final class MpvPlayer { } case .restarted: seeking = false + case .reconfigured: + // mpv has just read the drawable to size its output by. + outputSize = layer.drawableSize case let .ended(reason): // The outgoing file's END_FILE arrives after the next load began. if loading { return } diff --git a/ios/ProtonStream/Player/PlayerView.swift b/ios/ProtonStream/Player/PlayerView.swift index 346a8e0..7db0b69 100644 --- a/ios/ProtonStream/Player/PlayerView.swift +++ b/ios/ProtonStream/Player/PlayerView.swift @@ -724,16 +724,18 @@ struct MpvView: UIViewRepresentable { let player: MpvPlayer func makeUIView(context _: Context) -> LayerView { - LayerView(layer: player.layer) + LayerView(player: player) } func updateUIView(_: LayerView, context _: Context) {} final class LayerView: UIView { + private let player: MpvPlayer private let video: CAMetalLayer - init(layer video: CAMetalLayer) { - self.video = video + init(player: MpvPlayer) { + self.player = player + video = player.layer super.init(frame: .zero) backgroundColor = .black video.removeFromSuperlayer() @@ -759,6 +761,8 @@ struct MpvView: UIViewRepresentable { video.contentsScale = scale video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale) CATransaction.commit() + // A new drawable is not enough on its own: mpv has to be told. + player.outputResized() } override func didMoveToWindow() {