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:
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user