From bdd0058c5c101d198695aeecdc69975c1d9f4a12 Mon Sep 17 00:00:00 2001 From: sravan Date: Fri, 7 Aug 2026 11:34:09 +0530 Subject: [PATCH] Hole-punch: render native video BEHIND a transparent WebView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../NativeCallPlugin/NativeCallPlugin.swift | 189 +++++++----------- server/public/home.html | 85 ++++++-- 2 files changed, 139 insertions(+), 135 deletions(-) diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index f9dd0a6..47ed122 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import WebKit import Capacitor import PushKit import CallKit @@ -31,6 +32,8 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "startScreenShare", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "stopScreenShare", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setHolePunch", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setTileZoom", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "syncVideoTiles", 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 — // only ever touched on the main thread. TileVideoView adds pinch-zoom/pan for shared screens. 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() { let config = CXProviderConfiguration(localizedName: "Biz Connect") @@ -122,6 +131,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega room = nil Task { await r?.disconnect() } 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) @@ -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). - private static let nameTag = 9001 - private static let muteTag = 9002 - - // Build a tile view (a container holding a LiveKit VideoView) with its native name label (bottom-left) + - // 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), - ]) + // Create a tile view and place it BEHIND the web content (hole-punch), so the web tile's own name/mute/ + // border/avatar render on top. No native overlays needed — the WebView draws them. + private func makeTileView(host: WKWebView, key: String) -> TileVideoView { + let v = TileVideoView(frame: .zero) + host.insertSubview(v, belowSubview: host.scrollView) tileViews[key] = v return v } - private func updateTileOverlay(_ vv: TileVideoView, name: String, muted: Bool) { - // Keep the overlays above the video renderer (VideoView adds its renderer when the track is set). - // Hide them while zoomed into a screen — a scaled name chip over content just gets in the way. - let zoomed = vv.isZoomed - if let label = vv.viewWithTag(NativeCallPlugin.nameTag) as? UILabel { - label.text = name; label.isHidden = zoomed || name.isEmpty; vv.bringSubviewToFront(label) - } - if let badge = vv.viewWithTag(NativeCallPlugin.muteTag) { - badge.isHidden = zoomed || !muted; vv.bringSubviewToFront(badge) + // 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. + @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 } + if on { + if !self.holePunchOn { + 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 // 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. @@ -379,15 +384,12 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega : self.cameraTrack(forUid: uid, isLocal: isLocal) else { continue } wanted.insert(key) let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key) - if vv.superview !== host { host.addSubview(vv) } - vv.isScreen = wantScreen // enables pinch-zoom on shared screens (resets zoom if turned off) + if vv.superview !== host { host.insertSubview(vv, belowSubview: host.scrollView) } // keep BEHIND web UI vv.layoutMode = wantScreen ? .fit : .fill if vv.track !== track { vv.track = track } - // The container always tracks the tile rect; the zoom transform lives on the INNER video, so - // this never fights an active zoom. + // The container tracks the tile rect; the zoom transform (web-forwarded via setTileZoom) lives on + // the INNER video, so this never fights an active zoom. 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. 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 -// `open`, so subclassing outside its module is illegal), so we compose instead. Pinch-to-zoom + pan apply to -// the INNER video while it's showing a shared SCREEN (so fine print is legible); double-tap resets. The -// container itself stays frame-synced to the web tile rect, so zooming never fights the position poll, and the -// name/mute overlays (added to the container by makeTileView) don't scale with the zoom. -final class TileVideoView: UIView, UIGestureRecognizerDelegate { +// `open`, so subclassing outside its module is illegal), so we compose. Under hole-punch the video sits BEHIND +// the (transparent) WebView, so touches never reach it — pinch-zoom is captured by the web and forwarded via +// applyZoom(). The container stays frame-synced to the web tile rect; the zoom transform lives on the inner +// video, so the two never fight. +final class TileVideoView: UIView { 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. var track: VideoTrack? { get { video.track } set { video.track = newValue } } @@ -564,12 +562,11 @@ final class TileVideoView: UIView, UIGestureRecognizerDelegate { override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = .black + backgroundColor = .clear // the web tile draws its own frame; gaps show the WebView's black bg 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 addSubview(video) - installZoomGestures() } 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) } - private func installZoomGestures() { - isUserInteractionEnabled = true - let pinch = UIPinchGestureRecognizer(target: self, action: #selector(onPinch(_:))); pinch.delegate = self - let pan = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))); pan.delegate = self - 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) + // Web-forwarded zoom (scale + translation in points). scale<=1 clears the transform. + func applyZoom(scale: CGFloat, tx: CGFloat, ty: CGFloat) { + if scale <= 1.001 { if !video.transform.isIdentity { video.transform = .identity } } + else { video.transform = CGAffineTransform(translationX: tx, y: ty).scaledBy(x: scale, y: scale) } } } diff --git a/server/public/home.html b/server/public/home.html index ec176ff..a5096b7 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -417,6 +417,12 @@ .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: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){ .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;} @@ -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 // 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'); } +let bzHolePunch=false; // hole-punch active: native video is BEHIND a transparent WebView, so web UI floats on top function bzNativeSyncTiles(){ if(!meetNative) 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=[]; - // Native views draw ON TOP of the WebView, so a full-height tile would cover the control bar. Never let a - // 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'); + // Legacy fallback clamp: keep on-top video from covering the control bar (unnecessary under hole-punch). let maxBottom=Infinity; - 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; } + if(!bzHolePunch){ const bar=document.querySelector('#meetGrid ~ .meet-bar')||document.querySelector('.meet-bar'); const gridEl=document.getElementById('meetGrid'); + 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=>{ - 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 id=el.id ? el.id.replace('meet-tile-','') : ''; if(!id || id==='__waiting'){ return; } + const r=el.getBoundingClientRect(); if(r.width<2||r.height<2){ el.classList.remove('bz-hasvid'); return; } let h=r.height; - if(r.top < maxBottom && (r.top + h) > maxBottom) h = maxBottom - r.top; // clamp so it never covers the controls - if(h<2) return; - let uid, local=false, name='', muted=false, screen=false; - if(id==='__local'){ uid=(ME&&ME.id)||''; local=true; name=(ME&&ME.name)||'You'; muted=!meetMic; } - 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 - if(!uid) return; + if(r.top < maxBottom && (r.top + h) > maxBottom) h = maxBottom - r.top; // legacy clamp + if(h<2){ el.classList.remove('bz-hasvid'); return; } + 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; camOn=!!meetCam; } + 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){ 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 }); }); try{ NC.syncVideoTiles({ tiles }); }catch(_){} } -function bzNativeStartTiles(){ if(_ncTileTimer||!meetNative) 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(_){} } } +function bzNativeStartTiles(){ + 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){ 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