Native video: name + mute labels on the native video tiles

The native video covers the web tile's name/mute badges, so redraw those
natively. syncVideoTiles now also carries each tile's name + muted state;
each tile VideoView gets a bottom-left name label (with shadow for
legibility) and a bottom-right mic-slash badge shown when that participant
is muted. Kept above the video renderer via bringSubviewToFront. Web sends
name/muted from meetNames/meetMuted (and ME.name/!meetMic for __local).

Front/back camera switch + screen-share rendering still deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 14:10:54 +05:30
parent 4415225407
commit 34bdd8ea16
2 changed files with 64 additions and 13 deletions
@@ -1,4 +1,5 @@
import Foundation import Foundation
import UIKit
import Capacitor import Capacitor
import PushKit import PushKit
import CallKit import CallKit
@@ -148,6 +149,62 @@ 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 VideoView with its native name label (bottom-left) + mute badge (bottom-right) the video
// covers the web tile, so these redraw the essentials the web can no longer show through.
private func makeTileView(host: UIView, key: String) -> VideoView {
let v = VideoView()
v.layoutMode = .fill
v.backgroundColor = .black
v.clipsToBounds = true
v.layer.cornerRadius = 8
host.addSubview(v)
let label = UILabel()
label.tag = NativeCallPlugin.nameTag
label.font = .systemFont(ofSize: 12, weight: .semibold)
label.textColor = .white
label.shadowColor = UIColor.black.withAlphaComponent(0.9) // legible over any video
label.shadowOffset = CGSize(width: 0, height: 1)
label.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(label)
let badge = UIImageView(image: UIImage(systemName: "mic.slash.fill"))
badge.tag = NativeCallPlugin.muteTag
badge.tintColor = .white
badge.contentMode = .center
badge.backgroundColor = UIColor.black.withAlphaComponent(0.55)
badge.layer.cornerRadius = 9
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: -5),
label.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -4),
badge.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -6),
badge.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -5),
badge.widthAnchor.constraint(equalToConstant: 18),
badge.heightAnchor.constraint(equalToConstant: 18),
])
tileViews[key] = v
return v
}
private func updateTileOverlay(_ vv: VideoView, name: String, muted: Bool) {
// Keep the overlays above the video renderer (VideoView adds its renderer when the track is set).
if let label = vv.viewWithTag(NativeCallPlugin.nameTag) as? UILabel {
label.text = name; label.isHidden = name.isEmpty; vv.bringSubviewToFront(label)
}
if let badge = vv.viewWithTag(NativeCallPlugin.muteTag) {
badge.isHidden = !muted; vv.bringSubviewToFront(badge)
}
}
// 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.
@@ -263,19 +320,12 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
let key = self.tileKey(uid: uid, isLocal: isLocal) let key = self.tileKey(uid: uid, isLocal: isLocal)
guard let track = self.cameraTrack(forUid: uid, isLocal: isLocal) else { continue } // camera off leave the web avatar guard let track = self.cameraTrack(forUid: uid, isLocal: isLocal) else { continue } // camera off leave the web avatar
wanted.insert(key) wanted.insert(key)
let vv = self.tileViews[key] ?? { let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key)
let v = VideoView()
v.layoutMode = .fill
v.backgroundColor = .black
v.clipsToBounds = true
v.layer.cornerRadius = 8
host.addSubview(v)
self.tileViews[key] = v
return v
}()
if vv.superview !== host { host.addSubview(vv) } if vv.superview !== host { host.addSubview(vv) }
if vv.track !== track { vv.track = track } if vv.track !== track { vv.track = track }
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) {
+4 -3
View File
@@ -5376,10 +5376,11 @@ function bzNativeSyncTiles(){
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) return;
let uid, local=false; let uid, local=false, name='', muted=false;
if(id==='__local'){ uid=(ME&&ME.id)||''; local=true; } else { uid=meetPeerUids.get(id)||''; } 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); }
if(!uid) return; if(!uid) return;
tiles.push({ uid, local, x:r.left, y:r.top, w:r.width, h:r.height }); tiles.push({ uid, local, name, muted, x:r.left, y:r.top, w:r.width, h:r.height });
}); });
try{ NC.syncVideoTiles({ tiles }); }catch(_){} try{ NC.syncVideoTiles({ tiles }); }catch(_){}
} }