Hole-punch: render native video BEHIND a transparent WebView

Permanent fix for the z-order whack-a-mole (controls/menus/panels hiding
behind the native video). Instead of drawing native video ON TOP of the
WebView, draw it BEHIND a transparent WebView so ALL web UI floats on top
naturally — no suppressing, no clamping, no docked-only bar.

Plugin:
- setHolePunch(on): webView.isOpaque=false + black layer bg + clear scroll
  bg (restored on off / call end). Tiles inserted belowSubview:scrollView.
- Dropped native name/mute overlays (the web tile's own .nm/.meet-mute/avatar
  render on top now) and the PaddingLabel.
- Zoom re-plumbed: touches hit the WebView, so native gesture zoom can't work;
  new setTileZoom({uid,scale,tx,ty}) applies a web-forwarded transform to the
  tile's inner video. TileVideoView simplified to a container + applyZoom.

Web:
- bzNativeStartTiles/StopTiles toggle NC.setHolePunch + a body.bz-hp class
  (only when the plugin supports it — old builds keep the suppress fallback).
- Tiles with live native video get .bz-hasvid → CSS makes them transparent +
  hides the web avatar so the video shows through; name/mute/border stay.
- New web-forwarded pinch/pan/double-tap on the shared screen → setTileZoom.
- Suppress-on-overlay + the height clamp now only apply when NOT hole-punched.

Needs a Codemagic build (plugin). Web deployed; no-ops to the prior behavior
on builds without setHolePunch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 11:34:09 +05:30
parent f971908d80
commit bdd0058c5c
2 changed files with 139 additions and 135 deletions
@@ -1,5 +1,6 @@
import Foundation import Foundation
import UIKit import UIKit
import WebKit
import Capacitor import Capacitor
import PushKit import PushKit
import CallKit import CallKit
@@ -31,6 +32,8 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "startScreenShare", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "startScreenShare", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopScreenShare", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "stopScreenShare", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setHolePunch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setTileZoom", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise) CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
] ]
@@ -49,6 +52,12 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
// drawn over the WebView and positioned to match the web meeting tiles (see syncVideoTiles). UIKit views // drawn over the WebView and positioned to match the web meeting tiles (see syncVideoTiles). UIKit views
// only ever touched on the main thread. TileVideoView adds pinch-zoom/pan for shared screens. // only ever touched on the main thread. TileVideoView adds pinch-zoom/pan for shared screens.
private var tileViews: [String: TileVideoView] = [:] private var tileViews: [String: TileVideoView] = [:]
// 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?
override public func load() { override public func load() {
let config = CXProviderConfiguration(localizedName: "Biz Connect") let config = CXProviderConfiguration(localizedName: "Biz Connect")
@@ -122,6 +131,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
room = nil room = nil
Task { await r?.disconnect() } Task { await r?.disconnect() }
removeAllTileViews() removeAllTileViews()
DispatchQueue.main.async { [weak self] in guard let self = self, let web = self.bridge?.webView else { return }; self.applyHolePunchRestore(web) }
} }
// MARK: - Native video (tile rendering, Increment 2b) // MARK: - Native video (tile rendering, Increment 2b)
@@ -167,65 +177,60 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
} }
} }
// Tags for the overlay subviews we add to each tile's VideoView (name label + mute badge). // Create a tile view and place it BEHIND the web content (hole-punch), so the web tile's own name/mute/
private static let nameTag = 9001 // border/avatar render on top. No native overlays needed the WebView draws them.
private static let muteTag = 9002 private func makeTileView(host: WKWebView, key: String) -> TileVideoView {
let v = TileVideoView(frame: .zero)
// Build a tile view (a container holding a LiveKit VideoView) with its native name label (bottom-left) + host.insertSubview(v, belowSubview: host.scrollView)
// mute badge (top-left) the video covers the web tile, so these redraw the essentials.
private func makeTileView(host: UIView, key: String) -> TileVideoView {
let v = TileVideoView(frame: .zero) // sets up its inner VideoView + zoom gestures + cosmetics in init
host.addSubview(v)
// Name chip (bottom-left): white text on a dark translucent pill so it's legible over ANY video
// (plain white text vanished on bright footage).
let label = PaddingLabel()
label.tag = NativeCallPlugin.nameTag
label.font = .systemFont(ofSize: 12, weight: .semibold)
label.textColor = .white
label.backgroundColor = UIColor.black.withAlphaComponent(0.5)
label.layer.cornerRadius = 7
label.clipsToBounds = true
label.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(label)
// Mute badge (top-left): a RED circle with a white mic-slash matches the web tile's .meet-mute (#dc2626).
let badge = UIImageView(image: UIImage(systemName: "mic.slash.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 11, weight: .semibold)))
badge.tag = NativeCallPlugin.muteTag
badge.tintColor = .white
badge.contentMode = .center
badge.backgroundColor = UIColor(red: 0.863, green: 0.149, blue: 0.149, alpha: 1) // #dc2626
badge.layer.cornerRadius = 10
badge.clipsToBounds = true
badge.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(badge)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 6),
label.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -6),
label.trailingAnchor.constraint(lessThanOrEqualTo: v.trailingAnchor, constant: -6),
badge.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 6),
badge.topAnchor.constraint(equalTo: v.topAnchor, constant: 6),
badge.widthAnchor.constraint(equalToConstant: 20),
badge.heightAnchor.constraint(equalToConstant: 20),
])
tileViews[key] = v tileViews[key] = v
return v return v
} }
private func updateTileOverlay(_ vv: TileVideoView, name: String, muted: Bool) { // Turn hole-punch on/off. On: make the WebView transparent (its black layer bg shows where the web page is
// Keep the overlays above the video renderer (VideoView adds its renderer when the track is set). // transparent, i.e., over the meeting tiles) so the native video BEHIND it shows through. Off: restore.
// Hide them while zoomed into a screen a scaled name chip over content just gets in the way. @objc func setHolePunch(_ call: CAPPluginCall) {
let zoomed = vv.isZoomed let on = call.getBool("on") ?? false
if let label = vv.viewWithTag(NativeCallPlugin.nameTag) as? UILabel { DispatchQueue.main.async { [weak self] in
label.text = name; label.isHidden = zoomed || name.isEmpty; vv.bringSubviewToFront(label) guard let self = self, let web = self.bridge?.webView else { call.resolve(); return }
} if on {
if let badge = vv.viewWithTag(NativeCallPlugin.muteTag) { if !self.holePunchOn {
badge.isHidden = zoomed || !muted; vv.bringSubviewToFront(badge) self.savedWebOpaque = web.isOpaque
self.savedWebBg = web.backgroundColor
self.savedScrollBg = web.scrollView.backgroundColor
}
web.isOpaque = false
web.backgroundColor = .black // shows through the transparent meeting; also the letterbox bg
web.scrollView.backgroundColor = .clear
self.holePunchOn = true
} else {
self.applyHolePunchRestore(web)
for (_, vv) in self.tileViews { vv.removeFromSuperview() }
self.tileViews.removeAll()
}
call.resolve()
} }
} }
// Restore the WebView to opaque (must run on main).
private func applyHolePunchRestore(_ web: WKWebView) {
guard holePunchOn else { return }
web.isOpaque = savedWebOpaque
web.backgroundColor = savedWebBg
web.scrollView.backgroundColor = savedScrollBg
holePunchOn = false
}
// Web-forwarded zoom: touches land on the WebView (on top), so the web captures pinch/pan on the shared
// screen and forwards the transform here; we apply it to that tile's inner video.
@objc func setTileZoom(_ call: CAPPluginCall) {
let key = tileKey(uid: call.getString("uid") ?? "", isLocal: call.getBool("local") ?? false)
let scale = CGFloat(call.getDouble("scale") ?? 1)
let tx = CGFloat(call.getDouble("tx") ?? 0)
let ty = CGFloat(call.getDouble("ty") ?? 0)
DispatchQueue.main.async { [weak self] in self?.tileViews[key]?.applyZoom(scale: scale, tx: tx, ty: ty) }
call.resolve()
}
// Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected // Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected
// wired/Bluetooth headset (any non-receiver route) still wins. Re-asserted on mute toggles because enabling // wired/Bluetooth headset (any non-receiver route) still wins. Re-asserted on mute toggles because enabling
// the mic can flip the route back to the earpiece. // the mic can flip the route back to the earpiece.
@@ -379,15 +384,12 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
: self.cameraTrack(forUid: uid, isLocal: isLocal) else { continue } : self.cameraTrack(forUid: uid, isLocal: isLocal) else { continue }
wanted.insert(key) wanted.insert(key)
let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key) let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key)
if vv.superview !== host { host.addSubview(vv) } if vv.superview !== host { host.insertSubview(vv, belowSubview: host.scrollView) } // keep BEHIND web UI
vv.isScreen = wantScreen // enables pinch-zoom on shared screens (resets zoom if turned off)
vv.layoutMode = wantScreen ? .fit : .fill vv.layoutMode = wantScreen ? .fit : .fill
if vv.track !== track { vv.track = track } if vv.track !== track { vv.track = track }
// The container always tracks the tile rect; the zoom transform lives on the INNER video, so // The container tracks the tile rect; the zoom transform (web-forwarded via setTileZoom) lives on
// this never fights an active zoom. // the INNER video, so this never fights an active zoom.
vv.frame = CGRect(x: x, y: y, width: w, height: h) vv.frame = CGRect(x: x, y: y, width: w, height: h)
// Native video covers the web tile, so redraw the essentials (name + muted) natively.
self.updateTileOverlay(vv, name: (t["name"] as? String) ?? "", muted: (t["muted"] as? Bool) ?? false)
} }
// Drop views for participants no longer present / camera turned off. // Drop views for participants no longer present / camera turned off.
for (key, vv) in self.tileViews where !wanted.contains(key) { for (key, vv) in self.tileViews where !wanted.contains(key) {
@@ -547,16 +549,12 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
} }
// A tile view = a container holding a LiveKit VideoView. We CANNOT subclass VideoView (it's `public`, not // A tile view = a container holding a LiveKit VideoView. We CANNOT subclass VideoView (it's `public`, not
// `open`, so subclassing outside its module is illegal), so we compose instead. Pinch-to-zoom + pan apply to // `open`, so subclassing outside its module is illegal), so we compose. Under hole-punch the video sits BEHIND
// the INNER video while it's showing a shared SCREEN (so fine print is legible); double-tap resets. The // the (transparent) WebView, so touches never reach it pinch-zoom is captured by the web and forwarded via
// container itself stays frame-synced to the web tile rect, so zooming never fights the position poll, and the // applyZoom(). The container stays frame-synced to the web tile rect; the zoom transform lives on the inner
// name/mute overlays (added to the container by makeTileView) don't scale with the zoom. // video, so the two never fight.
final class TileVideoView: UIView, UIGestureRecognizerDelegate { final class TileVideoView: UIView {
let video = VideoView() let video = VideoView()
var isScreen = false { didSet { if !isScreen { resetZoom() } } }
var isZoomed: Bool { !video.transform.isIdentity }
private var zoomScale: CGFloat = 1
private var zoomOffset: CGPoint = .zero
// Forward the two properties the plugin sets so call sites read like a VideoView. // Forward the two properties the plugin sets so call sites read like a VideoView.
var track: VideoTrack? { get { video.track } set { video.track = newValue } } var track: VideoTrack? { get { video.track } set { video.track = newValue } }
@@ -564,12 +562,11 @@ final class TileVideoView: UIView, UIGestureRecognizerDelegate {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .black backgroundColor = .clear // the web tile draws its own frame; gaps show the WebView's black bg
clipsToBounds = true clipsToBounds = true
layer.cornerRadius = 8 layer.cornerRadius = 12 // match .meet-tile's border-radius so corners don't poke past the web border
video.layoutMode = .fill video.layoutMode = .fill
addSubview(video) addSubview(video)
installZoomGestures()
} }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
@@ -580,53 +577,9 @@ final class TileVideoView: UIView, UIGestureRecognizerDelegate {
video.center = CGPoint(x: bounds.midX, y: bounds.midY) video.center = CGPoint(x: bounds.midX, y: bounds.midY)
} }
private func installZoomGestures() { // Web-forwarded zoom (scale + translation in points). scale<=1 clears the transform.
isUserInteractionEnabled = true func applyZoom(scale: CGFloat, tx: CGFloat, ty: CGFloat) {
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(onPinch(_:))); pinch.delegate = self if scale <= 1.001 { if !video.transform.isIdentity { video.transform = .identity } }
let pan = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))); pan.delegate = self else { video.transform = CGAffineTransform(translationX: tx, y: ty).scaledBy(x: scale, y: scale) }
let dtap = UITapGestureRecognizer(target: self, action: #selector(onDoubleTap)); dtap.numberOfTapsRequired = 2
addGestureRecognizer(pinch); addGestureRecognizer(pan); addGestureRecognizer(dtap)
}
func resetZoom() {
zoomScale = 1; zoomOffset = .zero
if !video.transform.isIdentity { video.transform = .identity }
}
private func apply() {
video.transform = CGAffineTransform(translationX: zoomOffset.x, y: zoomOffset.y).scaledBy(x: zoomScale, y: zoomScale)
}
@objc private func onPinch(_ g: UIPinchGestureRecognizer) {
guard isScreen else { return }
if g.state == .changed {
zoomScale = min(5, max(1, zoomScale * g.scale)); g.scale = 1
if zoomScale <= 1 { zoomOffset = .zero }
apply()
} else if (g.state == .ended || g.state == .cancelled), zoomScale <= 1.01 {
resetZoom()
}
}
@objc private func onPan(_ g: UIPanGestureRecognizer) {
guard isScreen, zoomScale > 1 else { return }
let t = g.translation(in: self); g.setTranslation(.zero, in: self)
zoomOffset.x += t.x; zoomOffset.y += t.y
apply()
}
@objc private func onDoubleTap() { guard isScreen else { return }; resetZoom() }
// Let pinch + pan run together for a natural zoom/pan.
func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
}
// A UILabel with padding, so the name chip has breathing room around its text.
final class PaddingLabel: UILabel {
var insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)
override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) }
override var intrinsicContentSize: CGSize {
let s = super.intrinsicContentSize
return CGSize(width: s.width + insets.left + insets.right, height: s.height + insets.top + insets.bottom)
} }
} }
+68 -17
View File
@@ -417,6 +417,12 @@
.meet-grid.sharing-mode.scr-full{flex-flow:column;min-height:0;} .meet-grid.sharing-mode.scr-full{flex-flow:column;min-height:0;}
.meet-grid.sharing-mode.scr-full .meet-tile.stage{width:100%;height:auto;flex:1 1 auto;min-height:0;} .meet-grid.sharing-mode.scr-full .meet-tile.stage{width:100%;height:auto;flex:1 1 auto;min-height:0;}
.meet-grid.sharing-mode.scr-full .meet-tile:not(.stage){display:none;} .meet-grid.sharing-mode.scr-full .meet-tile:not(.stage){display:none;}
/* Hole-punch: native video renders BEHIND a transparent WebView, so all web UI (bar/menus/panels) floats on
top. Make the meeting transparent so the video shows through; on tiles that have native video, hide the web
avatar/bg (video shows) while keeping the name/mute/border the web draws. */
body.bz-hp .meet-grid{background:transparent;}
body.bz-hp .meet-tile.bz-hasvid{background:transparent;}
body.bz-hp .meet-tile.bz-hasvid .meet-av{display:none;}
@media (max-width:760px){ @media (max-width:760px){
.meet-grid.sharing-mode{flex-flow:row wrap;height:auto;} .meet-grid.sharing-mode{flex-flow:row wrap;height:auto;}
.meet-grid.sharing-mode .meet-tile.stage{width:100%;height:auto;flex:1 1 100%;min-height:40vh;} .meet-grid.sharing-mode .meet-tile.stage{width:100%;height:auto;flex:1 1 100%;min-height:40vh;}
@@ -5395,34 +5401,79 @@ let _ncTileTimer=null;
// on top of the WebView). While one is open, clear the native video so the web UI is usable; the poll restores // on top of the WebView). While one is open, clear the native video so the web UI is usable; the poll restores
// it when they close. (Covers the More/audio menus, participant panel, meeting chat, modals, image lightbox.) // it when they close. (Covers the More/audio menus, participant panel, meeting chat, modals, image lightbox.)
function bzMeetOverlayOpen(){ return !!document.querySelector('.meet-panel, .spk-menu, .modal-ov, .lightbox'); } function bzMeetOverlayOpen(){ return !!document.querySelector('.meet-panel, .spk-menu, .modal-ov, .lightbox'); }
let bzHolePunch=false; // hole-punch active: native video is BEHIND a transparent WebView, so web UI floats on top
function bzNativeSyncTiles(){ function bzNativeSyncTiles(){
if(!meetNative) return; if(!meetNative) return;
const NC=nativeCallPlugin(); if(!NC||!NC.syncVideoTiles) return; const NC=nativeCallPlugin(); if(!NC||!NC.syncVideoTiles) return;
if(bzMeetOverlayOpen()){ try{ NC.syncVideoTiles({ tiles:[] }); }catch(_){} return; } // a menu/panel is open → don't cover it // Legacy (no hole-punch): native video is ON TOP, so a menu/panel would be hidden behind it — clear it while
// one is open. With hole-punch the video is behind a transparent WebView, so web UI renders on top naturally.
if(!bzHolePunch && bzMeetOverlayOpen()){ try{ NC.syncVideoTiles({ tiles:[] }); }catch(_){} return; }
const tiles=[]; const tiles=[];
// Native views draw ON TOP of the WebView, so a full-height tile would cover the control bar. Never let a // Legacy fallback clamp: keep on-top video from covering the control bar (unnecessary under hole-punch).
// tile's native video extend past the top of the meeting controls (or, in normal grid mode, below the grid).
const bar=document.querySelector('#meetGrid ~ .meet-bar') || document.querySelector('.meet-bar');
const gridEl=document.getElementById('meetGrid');
let maxBottom=Infinity; let maxBottom=Infinity;
if(bar){ const br=bar.getBoundingClientRect(); if(br.height>0) maxBottom=br.top; } if(!bzHolePunch){ const bar=document.querySelector('#meetGrid ~ .meet-bar')||document.querySelector('.meet-bar'); const gridEl=document.getElementById('meetGrid');
else if(gridEl){ const gr=gridEl.getBoundingClientRect(); if(gr.height>0) maxBottom=gr.bottom; } if(bar){ const br=bar.getBoundingClientRect(); if(br.height>0) maxBottom=br.top; } else if(gridEl){ const gr=gridEl.getBoundingClientRect(); if(gr.height>0) maxBottom=gr.bottom; } }
document.querySelectorAll('#meetGrid .meet-tile').forEach(el=>{ document.querySelectorAll('#meetGrid .meet-tile').forEach(el=>{
const id=el.id ? el.id.replace('meet-tile-','') : ''; if(!id || id==='__waiting') return; const id=el.id ? el.id.replace('meet-tile-','') : ''; if(!id || id==='__waiting'){ return; }
const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) return; const r=el.getBoundingClientRect(); if(r.width<2||r.height<2){ el.classList.remove('bz-hasvid'); return; }
let h=r.height; let h=r.height;
if(r.top < maxBottom && (r.top + h) > maxBottom) h = maxBottom - r.top; // clamp so it never covers the controls if(r.top < maxBottom && (r.top + h) > maxBottom) h = maxBottom - r.top; // legacy clamp
if(h<2) return; if(h<2){ el.classList.remove('bz-hasvid'); return; }
let uid, local=false, name='', muted=false, screen=false; let uid, local=false, name='', muted=false, screen=false, camOn=false;
if(id==='__local'){ uid=(ME&&ME.id)||''; local=true; name=(ME&&ME.name)||'You'; muted=!meetMic; } if(id==='__local'){ uid=(ME&&ME.id)||''; local=true; name=(ME&&ME.name)||'You'; muted=!meetMic; camOn=!!meetCam; }
else { uid=meetPeerUids.get(id)||''; name=meetNames.get(id)||'Guest'; muted=!!meetMuted.get(id); screen=meetSharers.has(id); } // sharer's tile → render their screen, not camera else { uid=meetPeerUids.get(id)||''; name=meetNames.get(id)||'Guest'; muted=!!meetMuted.get(id); screen=meetSharers.has(id); camOn=(meetCamOff.get(id)!==true); }
if(!uid) return; if(!uid){ el.classList.remove('bz-hasvid'); return; }
// hole-punch: on tiles that have native video, hide the web avatar/bg so the video (behind) shows through.
el.classList.toggle('bz-hasvid', bzHolePunch && (screen || camOn));
tiles.push({ uid, local, name, muted, screen, x:r.left, y:r.top, w:r.width, h }); tiles.push({ uid, local, name, muted, screen, x:r.left, y:r.top, w:r.width, h });
}); });
try{ NC.syncVideoTiles({ tiles }); }catch(_){} try{ NC.syncVideoTiles({ tiles }); }catch(_){}
} }
function bzNativeStartTiles(){ if(_ncTileTimer||!meetNative) return; _ncTileTimer=setInterval(bzNativeSyncTiles, 400); bzNativeSyncTiles(); } function bzNativeStartTiles(){
function bzNativeStopTiles(){ if(_ncTileTimer){ clearInterval(_ncTileTimer); _ncTileTimer=null; } const NC=nativeCallPlugin(); if(NC&&NC.syncVideoTiles){ try{ NC.syncVideoTiles({ tiles:[] }); }catch(_){} } } if(!meetNative) return;
const NC=nativeCallPlugin();
if(NC && NC.setHolePunch){ bzHolePunch=true; try{ NC.setHolePunch({ on:true }); }catch(_){} document.body.classList.add('bz-hp'); } // permanent fix: video behind transparent WebView
if(_ncTileTimer) return; _ncTileTimer=setInterval(bzNativeSyncTiles, 400); bzNativeSyncTiles();
}
function bzNativeStopTiles(){
if(_ncTileTimer){ clearInterval(_ncTileTimer); _ncTileTimer=null; }
const NC=nativeCallPlugin(); if(NC && NC.syncVideoTiles){ try{ NC.syncVideoTiles({ tiles:[] }); }catch(_){} }
if(bzHolePunch){ bzHolePunch=false; if(NC && NC.setHolePunch){ try{ NC.setHolePunch({ on:false }); }catch(_){} } }
document.body.classList.remove('bz-hp');
document.querySelectorAll('#meetGrid .meet-tile.bz-hasvid').forEach(el=>el.classList.remove('bz-hasvid'));
}
// Web-forwarded pinch-zoom for a shared SCREEN under hole-punch. The native video is BEHIND the WebView, so
// touches land here — capture pinch / pan (when zoomed) / double-tap-reset on the sharer's tile and forward the
// transform to the plugin, which applies it to that tile's video.
(function(){
let el=null, uid=null, scale=1, tx=0, ty=0, pinchD=0, baseScale=1, sx=0, sy=0, panning=false, lastTap=0;
const dist=(t)=>Math.hypot(t[0].clientX-t[1].clientX, t[0].clientY-t[1].clientY);
const send=()=>{ const NC=nativeCallPlugin(); if(NC&&NC.setTileZoom&&uid){ try{ NC.setTileZoom({ uid, local:false, scale, tx, ty }); }catch(_){} } };
function pick(target){
if(!meetNative || !bzHolePunch) return null;
const t=target&&target.closest&&target.closest('#meetGrid .meet-tile'); if(!t) return null; // a menu/button over the tile isn't inside it → no zoom
const id=t.id.replace('meet-tile-',''); if(id==='__local' || !meetSharers.has(id)) return null; // only the shared screen
return { el:t, uid: meetPeerUids.get(id)||'' };
}
document.addEventListener('touchstart',(e)=>{
const s=pick(e.target); if(!s || !s.uid){ el=null; return; }
if(el!==s.el){ scale=1; tx=0; ty=0; } // switched to a different shared screen → reset
el=s.el; uid=s.uid;
if(e.touches.length===2){ pinchD=dist(e.touches); baseScale=scale; panning=false; }
else if(e.touches.length===1){
const now=Date.now();
if(now-lastTap<300){ scale=1; tx=0; ty=0; send(); lastTap=0; panning=false; return; } // double-tap reset
lastTap=now;
if(scale>1){ panning=true; sx=e.touches[0].clientX; sy=e.touches[0].clientY; }
}
}, {passive:true});
document.addEventListener('touchmove',(e)=>{
if(!el) return;
if(e.touches.length===2 && pinchD){ scale=Math.min(5, Math.max(1, baseScale*(dist(e.touches)/pinchD))); if(scale<=1){ tx=0; ty=0; } send(); if(e.cancelable) e.preventDefault(); }
else if(panning && e.touches.length===1 && scale>1){ const t=e.touches[0]; tx+=(t.clientX-sx); ty+=(t.clientY-sy); sx=t.clientX; sy=t.clientY; send(); if(e.cancelable) e.preventDefault(); }
}, {passive:false});
document.addEventListener('touchend',()=>{ pinchD=0; panning=false; }, {passive:true});
})();
async function enterMeeting(code, audioOnly, opts){ async function enterMeeting(code, audioOnly, opts){
bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join