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
andplayer.videoAspectRatio = nil
before calling.play()
- Applying
videoAspectRatio
manually inmediaPlayerStateChanged
- Delaying with
DispatchQueue.main.asyncAfter(...)
- Ensuring
drawable
view is fully laid out - Setting
autoresizingMask
andclipsToBounds
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
}
}
}