Native video Increment 2a: publish local camera + self-view
Wire the camera button on native calls to the plugin instead of a
"not available" toast. New NativeCall.setCamera({on}) calls LiveKit
localParticipant.setCamera(enabled:) so the iOS user's camera is
published to the room — every web/desktop peer renders it via their
existing SFU subscription. Locally the plugin shows a small rounded
self-view (VideoView) pinned top-right over the WebView.
APIs verified against client-sdk-swift 2.15.3 source: setCamera ->
LocalTrackPublication?, TrackPublication.track, VideoView(.track/.layoutMode),
CameraCaptureOptions(position:.front). Front camera only for now.
Rendering the OTHER participants as native tiles synced to the web
meeting grid is Increment 2b (the fragile part) — next build. Until the
new IPA ships, the web branch falls back to an "update the app" toast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setCamera", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -39,6 +40,11 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
private var endedCalls = Set<UUID>()
|
private var endedCalls = Set<UUID>()
|
||||||
private var room: Room?
|
private var room: Room?
|
||||||
private var activeUUID: UUID?
|
private var activeUUID: UUID?
|
||||||
|
// Native video (Increment 2a): the local camera is published to the room (so everyone else sees this
|
||||||
|
// user) and mirrored into a small self-view (PiP) drawn over the WebView. Rendering the OTHER
|
||||||
|
// participants as native tiles synced to the web meeting grid is Increment 2b. VideoView is a UIKit
|
||||||
|
// view — only ever touched on the main thread (helpers below dispatch there).
|
||||||
|
private var localVideoView: VideoView?
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
let config = CXProviderConfiguration(localizedName: "Biz Connect")
|
let config = CXProviderConfiguration(localizedName: "Biz Connect")
|
||||||
@@ -91,6 +97,41 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
let r = room
|
let r = room
|
||||||
room = nil
|
room = nil
|
||||||
Task { await r?.disconnect() }
|
Task { await r?.disconnect() }
|
||||||
|
hideSelfView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Native video (local self-view)
|
||||||
|
|
||||||
|
// Show the local camera as a small rounded self-view pinned to the top-right safe area, above the WebView.
|
||||||
|
// Increment 2b will render the REMOTE participants as native views synced to the web meeting tiles.
|
||||||
|
private func showSelfView(track: VideoTrack?) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self, let track = track, let host = self.bridge?.viewController?.view else { return }
|
||||||
|
let vv = self.localVideoView ?? VideoView()
|
||||||
|
vv.layoutMode = .fill // fill the PiP box (crop) instead of letterboxing
|
||||||
|
vv.track = track
|
||||||
|
if vv.superview == nil {
|
||||||
|
vv.backgroundColor = .black
|
||||||
|
vv.clipsToBounds = true
|
||||||
|
vv.layer.cornerRadius = 10
|
||||||
|
vv.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
host.addSubview(vv)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
vv.widthAnchor.constraint(equalToConstant: 96),
|
||||||
|
vv.heightAnchor.constraint(equalToConstant: 132),
|
||||||
|
vv.topAnchor.constraint(equalTo: host.safeAreaLayoutGuide.topAnchor, constant: 16),
|
||||||
|
vv.trailingAnchor.constraint(equalTo: host.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
self.localVideoView = vv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideSelfView() {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.localVideoView?.removeFromSuperview()
|
||||||
|
self?.localVideoView = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -171,6 +212,26 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native video (Increment 2a): enable/disable the local camera. Publishing it makes this user's video
|
||||||
|
// appear for everyone else (their web/desktop clients render it via their own SFU subscription); locally
|
||||||
|
// we mirror it into a small self-view. Seeing the OTHER participants' video (native tiles) is Increment 2b.
|
||||||
|
// Front camera only for now.
|
||||||
|
@objc func setCamera(_ call: CAPPluginCall) {
|
||||||
|
guard let r = room else { call.reject("no active call"); return }
|
||||||
|
let on = call.getBool("on") ?? false
|
||||||
|
Task { [weak self] in
|
||||||
|
do {
|
||||||
|
let pub = try await r.localParticipant.setCamera(
|
||||||
|
enabled: on,
|
||||||
|
captureOptions: CameraCaptureOptions(position: .front))
|
||||||
|
if on { self?.showSelfView(track: pub?.track as? VideoTrack) } else { self?.hideSelfView() }
|
||||||
|
call.resolve(["on": on])
|
||||||
|
} catch {
|
||||||
|
call.reject("camera failed: \(String(describing: error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// End the CallKit call (remote hung up / user ended from the web UI). No callUUID -> end all.
|
// End the CallKit call (remote hung up / user ended from the web UI). No callUUID -> end all.
|
||||||
@objc func endCall(_ call: CAPPluginCall) {
|
@objc func endCall(_ call: CAPPluginCall) {
|
||||||
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) {
|
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) {
|
||||||
|
|||||||
+10
-1
@@ -5469,7 +5469,16 @@ async function toggleMic(){
|
|||||||
// renegotiates with every peer, so you can always turn video on once a meeting has started.
|
// renegotiates with every peer, so you can always turn video on once a meeting has started.
|
||||||
async function toggleCam(){
|
async function toggleCam(){
|
||||||
if(!meetLocalStream) return;
|
if(!meetLocalStream) return;
|
||||||
if(meetNative){ toast('Video isn’t available on native calls yet'); return; } // native path is audio-only for now (video rendering is the next phase)
|
if(meetNative){ // native call: the plugin owns the LiveKit media, so the camera is published natively.
|
||||||
|
const next=!meetCam; const NC=nativeCallPlugin();
|
||||||
|
if(!NC||!NC.setCamera){ toast('Update the app to use video on calls'); return; }
|
||||||
|
try{ await NC.setCamera({ on:next }); }catch(e){ toast('Could not turn the camera '+(next?'on':'off')); return; }
|
||||||
|
meetCam=next; meetAudioOnly=false; updateCamBtn();
|
||||||
|
// The web __local tile stays an avatar — the WebView has no camera stream in a native call (the plugin
|
||||||
|
// owns media and draws its own self-view). Tell peers our camera state so their SFU tile shows the video.
|
||||||
|
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
||||||
const hasTrack=meetLocalStream.getVideoTracks().length>0;
|
const hasTrack=meetLocalStream.getVideoTracks().length>0;
|
||||||
if(!hasTrack){
|
if(!hasTrack){
|
||||||
|
|||||||
Reference in New Issue
Block a user