Hole-punch attempt 3: set WebView transparent at startup + scrollView.isOpaque

Previous hole-punch builds showed no video because WKWebView can IGNORE
isOpaque=false when it's set AFTER the page has already rendered (during the
call). Now:
- makeWebViewTransparent() runs once at plugin load (startup): isOpaque=false,
  backgroundColor=.clear, and crucially scrollView.isOpaque=false +
  scrollView.backgroundColor=.clear (an opaque scrollView occludes content
  behind the WebView).
- setHolePunch now just toggles a BLACK backing on the WebView's parent during
  the call (transparency is already applied); restored after.
- Tile frames use host.convert(rect, to: superview) instead of a manual origin
  offset — robust to any WebView inset.

Plugin-only — needs a Codemagic build. If this still shows no video, WKWebView
hole-punch is a dead end here and we revert to native-video-on-top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 14:13:22 +05:30
parent d9ea104b73
commit c24d5d828a
@@ -55,10 +55,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
// Hole-punch: draw the native video BEHIND a transparent WebView so all web UI (bar, menus, panels) floats
// on top. Saved so we can restore the WebView when the call ends.
private var holePunchOn = false
private var savedWebOpaque = true
private var savedWebBg: UIColor?
private var savedScrollBg: UIColor?
private var savedVCBg: UIColor?
private var savedVCBg: UIColor? // the WebView parent's original background, restored when the call ends
override public func load() {
let config = CXProviderConfiguration(localizedName: "Biz Connect")
@@ -91,6 +88,20 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
// Screen sharing (ReplayKit broadcast extension). LiveKit tells us when a broadcast starts/stops and
// (with shouldPublishTrack=true, the default) auto-publishes/unpublishes the screen-share track.
BroadcastManager.shared.delegate = self
// Make the WebView transparent at STARTUP. WKWebView can IGNORE isOpaque=false when it's flipped after
// the page has already rendered the likely reason the hole-punch showed no video. Doing it once, up
// front, makes the transparent-meeting areas actually reveal the native video behind the WebView. The
// web body is opaque, so the app looks normal outside a call.
DispatchQueue.main.async { [weak self] in self?.makeWebViewTransparent() }
}
private func makeWebViewTransparent() {
guard let web = bridge?.webView else { return }
web.isOpaque = false
web.backgroundColor = .clear
web.scrollView.isOpaque = false // the scrollView being opaque can occlude native content behind the WebView
web.scrollView.backgroundColor = .clear
}
@objc private func audioRouteChanged(_ note: Notification) {
@@ -188,23 +199,18 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
return v
}
// Turn hole-punch on/off. On: make the WebView transparent (its black layer bg shows where the web page is
// transparent, i.e., over the meeting tiles) so the native video BEHIND it shows through. Off: restore.
// Turn hole-punch on/off. Transparency is already set at startup (makeWebViewTransparent); here we just add
// a BLACK backing to the WebView's parent (the layer directly behind the video tiles) during the call, and
// remove it + the tiles afterwards.
@objc func setHolePunch(_ call: CAPPluginCall) {
let on = call.getBool("on") ?? false
DispatchQueue.main.async { [weak self] in
guard let self = self, let web = self.bridge?.webView else { call.resolve(); return }
self.makeWebViewTransparent() // belt-and-braces
let parent = web.superview
if on {
if !self.holePunchOn {
self.savedWebOpaque = web.isOpaque
self.savedWebBg = web.backgroundColor
self.savedScrollBg = web.scrollView.backgroundColor
self.savedVCBg = self.bridge?.viewController?.view.backgroundColor
}
web.isOpaque = false
web.backgroundColor = .clear // fully transparent so the video BEHIND the WebView shows through
web.scrollView.backgroundColor = .clear
self.bridge?.viewController?.view.backgroundColor = .black // the black backing (behind the video) for gaps/letterbox
if !self.holePunchOn { self.savedVCBg = parent?.backgroundColor }
parent?.backgroundColor = .black
self.holePunchOn = true
} else {
self.applyHolePunchRestore(web)
@@ -215,13 +221,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
}
}
// Restore the WebView to opaque (must run on main).
// Remove the black backing (leave the WebView transparent the web body is opaque, so it looks normal).
private func applyHolePunchRestore(_ web: WKWebView) {
guard holePunchOn else { return }
web.isOpaque = savedWebOpaque
web.backgroundColor = savedWebBg
web.scrollView.backgroundColor = savedScrollBg
bridge?.viewController?.view.backgroundColor = savedVCBg
web.superview?.backgroundColor = savedVCBg
holePunchOn = false
}
@@ -392,10 +395,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
if vv.superview !== host.superview, let sup = host.superview { sup.insertSubview(vv, belowSubview: host) } // keep BEHIND the transparent WebView
vv.layoutMode = wantScreen ? .fit : .fill
if vv.track !== track { vv.track = track }
// The container tracks the tile rect (getBoundingClientRect is in the WebView's viewport, so map
// it into the superview by adding the WebView's origin). The zoom transform (web-forwarded via
// setTileZoom) lives on the INNER video, so this never fights an active zoom.
vv.frame = CGRect(x: x + host.frame.minX, y: y + host.frame.minY, width: w, height: h)
// The container tracks the tile rect. getBoundingClientRect is in the WebView's coordinate space;
// convert it into the superview (where the tiles live) robust to any WebView offset/inset. The
// zoom transform (web-forwarded via setTileZoom) lives on the INNER video, so this never fights it.
vv.frame = host.convert(CGRect(x: x, y: y, width: w, height: h), to: host.superview)
}
// Drop views for participants no longer present / camera turned off.
for (key, vv) in self.tileViews where !wanted.contains(key) {