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