diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index 7944e55..544fd3d 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit import Capacitor import PushKit 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 // 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. @@ -263,19 +320,12 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega 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 wanted.insert(key) - let vv = self.tileViews[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 - }() + let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key) if vv.superview !== host { host.addSubview(vv) } if vv.track !== track { vv.track = track } 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) { diff --git a/server/public/home.html b/server/public/home.html index dd8d462..479b119 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -5376,10 +5376,11 @@ function bzNativeSyncTiles(){ 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; - let uid, local=false; - if(id==='__local'){ uid=(ME&&ME.id)||''; local=true; } else { uid=meetPeerUids.get(id)||''; } + let uid, local=false, name='', muted=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); } 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(_){} }