MobileVLCKit: video aspect ratio not applied correctly the first time (videoAspectRatio, scaleFactor)

I’m working on a Capacitor plugin for iOS that uses MobileVLCKit to play an RTSP stream inside a UIView. The stream loads fine, and I manually adjust the aspect ratio using videoAspectRatio and scaleFactor = 0.0 after playback starts.

The problem is that the first time I open the stream (after launching the app), the aspect ratio is not applied. The video stretches incorrectly (typically fills the container, ignoring its native aspect). From the second time on, the video usually adjusts correctly.

I’m detecting the .playing state using the mediaPlayerStateChanged(_:) delegate method and apply the aspect ratio 0.5 seconds after it enters .playing. But this doesn’t always fire in time on the first run, or the adjustment is ignored.

Things I already tried:

  • Setting player.scaleFactor = 0.0 and player.videoAspectRatio = nil before calling .play()
  • Applying videoAspectRatio manually in mediaPlayerStateChanged
  • Delaying with DispatchQueue.main.asyncAfter(...)
  • Ensuring drawable view is fully laid out
  • Setting autoresizingMask and clipsToBounds correctly

My question:

How can I reliably apply videoAspectRatio so the video never stretches — not only on the first stream playback, but every single time the stream is started?

Is there a better hook or event than .playing to ensure this adjustment always works? Could this be a race condition or timing issue inside VLCMediaPlayer?

Any ideas or best practices?

Environment:

  • iOS 16+
  • Swift + Capacitor plugin
  • MobileVLCKit 3.3.16.3
  • Playing RTSP stream from Reolink camera

Photos:

Code:

import Foundation
import Capacitor
import MobileVLCKit

@objc(RtspVlcPlugin)
public class RtspVlcPlugin: CAPPlugin, CAPBridgedPlugin, VLCMediaPlayerDelegate {
    public let identifier = "RtspVlcPlugin"
    public let jsName = "RtspVlcPlugin"
    public let pluginMethods: [CAPPluginMethod] = [
        CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "show", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "hide", returnType: CAPPluginReturnPromise)
    ]

    private var mediaPlayer: VLCMediaPlayer?
    private var containerView: UIView?
    private var spinner: UIActivityIndicatorView?
    private var hasAdjustedVideo = false

    override public func load() {
        print("✅ RtspVlcPlugin successfully initialized.")
    }

    @objc func start(_ call: CAPPluginCall) {
        guard let urlStr = call.getString("url"),
              let x = call.getDouble("x"),
              let y = call.getDouble("y"),
              let w = call.getDouble("width"),
              let h = call.getDouble("height"),
              let url = URL(string: urlStr) else {
            call.reject("Invalid parameters")
            return
        }

        DispatchQueue.main.async {
            let scale = UIScreen.main.scale
            let ax = x / scale, ay = y / scale
            let aw = w / scale, ah = h / scale

            self.containerView?.removeFromSuperview()

            guard let webView = self.bridge?.webView,
                  let superview = webView.superview else {
                call.reject("WebView not available")
                return
            }

            let container = UIView(frame: CGRect(x: ax, y: ay, width: aw, height: ah))
            container.backgroundColor = .black
            container.layer.cornerRadius = 16
            container.clipsToBounds = true

            let videoView = UIView(frame: container.bounds)
            videoView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            videoView.contentMode = .scaleToFill
            videoView.clipsToBounds = true

            container.addSubview(videoView)

            let spinner = UIActivityIndicatorView(style: .large)
            spinner.center = CGPoint(x: aw / 2, y: ah / 2)
            spinner.color = .white
            spinner.startAnimating()
            container.addSubview(spinner)

            self.containerView = container
            self.spinner = spinner
            superview.addSubview(container)

            let media = VLCMedia(url: url)
            media.addOptions([
                "--rtsp-tcp": true,
                "--network-caching": 1000,
                "--file-caching": 1000,
                "--live-caching": 1000,
                "--clock-jitter": 0,
                "--clock-synchro": 0,
                "--udp-buffer": 524288
            ])

            let player = VLCMediaPlayer()
            player.delegate = self
            player.drawable = videoView
            player.media = media
            player.scaleFactor = 0.0
            player.videoAspectRatio = nil

            self.mediaPlayer = player
            self.hasAdjustedVideo = false
            player.play()

            call.resolve()
        }
    }

    @objc func stop(_ call: CAPPluginCall) {
        print("🔴 RtspVlcPlugin → stop called")

        DispatchQueue.main.async {
            self.mediaPlayer?.stop()
            self.mediaPlayer = nil

            self.spinner?.stopAnimating()
            self.spinner?.removeFromSuperview()
            self.spinner = nil

            self.containerView?.removeFromSuperview()
            self.containerView = nil

            self.hasAdjustedVideo = false

            call.resolve()
        }
    }

    @objc func hide(_ call: CAPPluginCall) {
        print("🙈 RtspVlcPlugin → hide called")

        DispatchQueue.main.async {
            self.containerView?.isHidden = true
            call.resolve()
        }
    }

    @objc func show(_ call: CAPPluginCall) {
        print("👁 RtspVlcPlugin → show called")

        DispatchQueue.main.async {
            self.containerView?.isHidden = false
            call.resolve()
        }
    }

    public func mediaPlayerStateChanged(_ notification: Notification!) {
        guard let player = mediaPlayer else { return }

        if player.state == .playing && !hasAdjustedVideo {
            print("▶️ Playback started, scheduling aspect ratio adjustment...")
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.applyAspectRatio()
            }
        }
    }

    private func applyAspectRatio() {
        guard let player = mediaPlayer,
              let container = self.containerView else { return }

        DispatchQueue.main.async {
            self.spinner?.stopAnimating()
            self.spinner?.removeFromSuperview()
            self.spinner = nil

            player.scaleFactor = 0.0
            let w = Int(container.frame.width)
            let h = Int(container.frame.height)
            let aspect = "\(w):\(h)"

            print("📺 Applying aspect ratio: \(aspect)")

            aspect.withCString { cStr in
                player.videoAspectRatio = UnsafeMutablePointer(mutating: cStr)
            }

            self.hasAdjustedVideo = true
        }
    }
}