Files
UnrealEngine/Engine/Extras/VirtualProduction/LiveLinkVCAM/vcam/WebRTCStreamingConnection.swift
2025-05-18 13:04:45 +08:00

640 lines
26 KiB
Swift

//
// WebRTCStreamingConnection.swift
// vcam
//
// Created by Brian Smith on 6/28/22.
// Copyright Epic Games, Inc. All Rights Reserved.
//
import UIKit
import WebRTC
class WebRTCStreamingConnection : StreamingConnection {
var _url : URL?
public var webRTCClient: WebRTCClient?
private var webRTCClientState : RTCIceConnectionState?
private var signalClient: SignalingClient?
private var touchControls: TouchControls?
private var keyboardControls: KeyboardControls?
private weak var webRTCView : WebRTCView?
private weak var rtcVideoTrack : RTCVideoTrack?
private var signalingConnected = false
private var hasRemoteSdp = false
private var hasLocalSdp = false
private var remoteCandidateCount = 0
private var localCandidateCount = 0
private var webRTCStats : WebRTCStats?
private var _statsTimer : Timer?
private var reconnectAttempt : Int = 0
private var maxReconnectAttempts : Int = 3
private var subscribedStreamer : String = ""
override var name : String {
get {
StreamingConnectionType.webRTC.rawValue
}
}
override var destination : String {
get {
self._url?.absoluteString ?? ""
}
set {
let host : String
let port : UInt16?
(host, port) = NetUtility.hostAndPortFromAddress(newValue)
self._url = URL(string: "ws://\(host):\(port ?? 80)")
}
}
override var isConnected: Bool {
get {
return (self.webRTCClientState ?? .disconnected) == .connected
}
}
override var relayTouchEvents: Bool {
didSet {
self.touchControls?.relayTouchEvents = relayTouchEvents
}
}
override var renderView: UIView? {
didSet {
if let rv = renderView {
// Attach webrtc video view to render view
let rtcView = WebRTCView(frame: CGRect(x: 0, y: 0, width: rv.frame.size.width, height: rv.frame.size.height))
rv.addSubview(rtcView)
rtcView.delegate = self
rtcView.layoutToSuperview(.top, .bottom, .left, .right)
self.webRTCView = rtcView
self.attachVideoTrack()
// Attach stats view to renderview
let rtcStatsView = WebRTCStatsView(frame: CGRect(x: 0, y: 0, width: rv.frame.size.width, height: rv.frame.size.height))
self.setupWebRTCStats(statsView: rtcStatsView)
rv.addSubview(rtcStatsView)
NSLayoutConstraint.activate([
rtcStatsView.topAnchor.constraint(equalTo: rv.topAnchor),
rtcStatsView.leadingAnchor.constraint(equalTo: rv.leadingAnchor),
rtcStatsView.trailingAnchor.constraint(equalTo: rv.trailingAnchor),
rtcStatsView.bottomAnchor.constraint(equalTo: rv.bottomAnchor)
])
}
}
}
required init(subjectName: String) {
super.init(subjectName: subjectName)
self.webRTCClient = WebRTCClient()
self.webRTCClient?.speakerOff()
self.webRTCClient?.delegate = self
self.stats = StreamingConnectionStats()
}
deinit {
Log.info("WebRTCStreamingConnection destructed")
webRTCClient = nil
}
func setupWebRTCStats(statsView: WebRTCStatsView) {
// Create stats object that drives the stats view
statsView.isHidden = true
self.webRTCStats = WebRTCStats(statsView: statsView)
// Start a timer to update the stats
_statsTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in
if let webRTC = self.webRTCClient {
webRTC.stats({ report in
self.webRTCStats?.processStatsReport(report: report)
self.stats?.framesPerSecond = Float(self.webRTCStats?.lastFPS ?? 0)
self.stats?.bytesPerSecond = Int(self.webRTCStats?.lastBitrate ?? 0)
})
} else {
self.stats = nil
}
})
}
override func showStats(_ shouldShow : Bool) {
if let rv = self.renderView {
for subview in rv.subviews {
if let rtcStatsView = subview as? WebRTCStatsView {
rtcStatsView.isHidden = !shouldShow
}
}
}
}
override func shutdown() {
_statsTimer?.invalidate()
disconnect()
}
override func connect() throws {
guard let url = self._url else {
throw StreamingConnectionError.runtimeError("The destination address wasn't formatted properly.")
}
if signalClient == nil {
// We will use 3rd party library for websockets.
let webSocketProvider: WebSocketProvider
if #available(iOS 13.0, *) {
webSocketProvider = NativeWebSocketProvider(url: url, timeout: 2.0)
} else {
webSocketProvider = StarscreamWebSocket(url: url)
}
self.signalClient = SignalingClient(webSocket: webSocketProvider)
self.signalClient?.delegate = self
self.signalClient?.connect()
}
}
override func reconnect() {
//disconnect()
//do {
// try connect()
//} catch {
// Log.error(error.localizedDescription)
//}
}
override func disconnect() {
signalClient?.delegate = nil
signalClient?.close()
signalClient = nil
signalingConnected = false
hasLocalSdp = false
hasRemoteSdp = false
remoteCandidateCount = 0
localCandidateCount = 0
// stop the WebRTC client
self.webRTCClient?.close()
// remove the video track from the webrtc view
self.detachVideoTrack()
// Clear the stats timer
self._statsTimer?.invalidate()
// Remove WebRTC view/WebRTC stats view from the render view on shutdown
if let rv = self.renderView {
for subview in rv.subviews {
subview.removeFromSuperview()
}
}
}
override func sendTransform(_ transform: simd_float4x4, atTime time: Double) {
self.webRTCStats?.processARKitEvent()
guard let client = webRTCClient else { return }
// convert the transform to UE space
// adapted from AppleARKitConversion.h
let rawRotation = simd_quaternion(transform)
let ueRotation = simd_quaternion(-rawRotation.vector.z, rawRotation.vector.x, rawRotation.vector.y, -rawRotation.vector.w)
var ueTransform = simd_float4x4(ueRotation)
ueTransform.columns.3 = simd_float4(x: -transform.columns.3.z, y: transform.columns.3.x, z: transform.columns.3.y, w: 1.0) * 100.0 // ue units
ueTransform.columns.3.w = 1.0 // Force Unit scale
var bytes: [UInt8] = []
// Write message type using 1 byte
bytes.append(PixelStreamingToStreamerMessage.Transform.rawValue)
// Write 4x4 transform each element to get 4 bytes e.g. float -> [UInt8]
bytes.append(contentsOf: ueTransform.columns.0.x.toBytes())
bytes.append(contentsOf: ueTransform.columns.0.y.toBytes())
bytes.append(contentsOf: ueTransform.columns.0.z.toBytes())
bytes.append(contentsOf: ueTransform.columns.0.w.toBytes())
bytes.append(contentsOf: ueTransform.columns.1.x.toBytes())
bytes.append(contentsOf: ueTransform.columns.1.y.toBytes())
bytes.append(contentsOf: ueTransform.columns.1.z.toBytes())
bytes.append(contentsOf: ueTransform.columns.1.w.toBytes())
bytes.append(contentsOf: ueTransform.columns.2.x.toBytes())
bytes.append(contentsOf: ueTransform.columns.2.y.toBytes())
bytes.append(contentsOf: ueTransform.columns.2.z.toBytes())
bytes.append(contentsOf: ueTransform.columns.2.w.toBytes())
bytes.append(contentsOf: ueTransform.columns.3.x.toBytes())
bytes.append(contentsOf: ueTransform.columns.3.y.toBytes())
bytes.append(contentsOf: ueTransform.columns.3.z.toBytes())
bytes.append(contentsOf: ueTransform.columns.3.w.toBytes())
// Write timestamp 8 bytes
bytes.append(contentsOf: time.toBytes())
// Send the transform + timestamp across
client.sendData(Data(bytes))
}
func inputTypeToIndex(_ type : StreamingConnectionControllerInputType) -> UInt8? {
switch type {
case .faceButtonBottom : return 0
case .faceButtonRight : return 1
case .faceButtonLeft : return 2
case .faceButtonTop : return 3
case .shoulderButtonLeft : return 4
case .shoulderButtonRight : return 5
case .triggerButtonLeft : return 6
case .triggerButtonRight : return 7
case .specialButtonLeft : return 8
case .specialButtonRight : return 9
case .thumbstickLeftButton : return 10
case .thumbstickRightButton : return 11
case .dpadUp : return 12
case .dpadDown : return 13
case .dpadLeft : return 14
case .dpadRight : return 15
case .thumbstickLeftX : return 1
case .thumbstickLeftY : return 2
case .thumbstickRightX : return 3
case .thumbstickRightY : return 4
}
}
override func sendControllerConnected() {
guard let client = webRTCClient else { return }
var bytes: [UInt8] = []
// Write message type using 1 byte
bytes.append(PixelStreamingToStreamerMessage.GamepadConnected.rawValue)
client.sendData(Data(bytes))
}
override func sendControllerAnalog(_ type : StreamingConnectionControllerInputType, controllerIndex : UInt8, value : Float) {
guard let client = webRTCClient else { return }
guard let analogIndex = inputTypeToIndex(type) else {
Log.warning("Couldn't find an index for controller input type \(type.rawValue)")
return
}
var bytes: [UInt8] = []
// Write message type using 1 byte
bytes.append(PixelStreamingToStreamerMessage.GamepadAnalog.rawValue)
bytes.append(controllerIndex)
bytes.append(analogIndex)
bytes.append(contentsOf: Double(value).toBytes())
client.sendData(Data(bytes))
}
override func sendControllerButtonPressed(_ type : StreamingConnectionControllerInputType, controllerIndex : UInt8, isRepeat : Bool) {
guard let client = webRTCClient else { return }
guard let buttonIndex = inputTypeToIndex(type) else {
Log.warning("Couldn't find an index for controller input type \(type.rawValue)")
return
}
var bytes: [UInt8] = []
bytes.append(PixelStreamingToStreamerMessage.GamepadButtonPressed.rawValue)
bytes.append(controllerIndex)
bytes.append(buttonIndex)
bytes.append(UInt8(isRepeat ? 1 : 0))
client.sendData(Data(bytes))
}
override func sendControllerButtonReleased(_ type : StreamingConnectionControllerInputType, controllerIndex : UInt8) {
guard let client = webRTCClient else { return }
guard let buttonIndex = inputTypeToIndex(type) else {
Log.warning("Couldn't find an index for controller input type \(type.rawValue)")
return
}
var bytes: [UInt8] = []
bytes.append(PixelStreamingToStreamerMessage.GamepadButtonReleased.rawValue)
bytes.append(controllerIndex)
bytes.append(buttonIndex)
client.sendData(Data(bytes))
}
override func sendControllerDisconnected(controllerIndex: UInt8) {
guard let client = webRTCClient else { return }
var bytes: [UInt8] = []
// Write message type using 1 byte
bytes.append(PixelStreamingToStreamerMessage.GamepadDisconnected.rawValue)
bytes.append(controllerIndex)
client.sendData(Data(bytes))
}
func attachVideoTrack() {
if let webRTC = webRTCClient, let view = self.webRTCView, let track = self.rtcVideoTrack {
self.touchControls = TouchControls(webRTC, touchView: view)
self.keyboardControls = KeyboardControls(webRTC)
view.attachVideoTrack(track: track)
view.attachTouchDelegate(delegate: self.touchControls!)
}
}
func detachVideoTrack() {
if let view = self.webRTCView, let track = self.rtcVideoTrack {
view.removeVideoTrack(track: track)
}
}
}
extension WebRTCStreamingConnection : WebRTCViewDelegate {
func webRTCView(_ view: WebRTCView, didChangeVideoSize size: CGSize) {
self.videoSize = size
}
}
extension WebRTCStreamingConnection: SignalClientDelegate {
func signalClientDidConnect(_ signalClient: SignalingClient) {
self.signalingConnected = true
Log.info("Connected to signaling server")
signalClient.sendRequestStreamerList();
}
func signalClientDidDisconnect(_ signalClient: SignalingClient, error: Error?) {
self.signalingConnected = false
Log.info("Disconnected from signaling server")
self.delegate?.streamingConnection(self, didDisconnectWithError: error)
}
func signalClientDidReceiveError(_ signalClient: SignalingClient, error: Error?) {
if error != nil {
Log.error("Signalling got error: \(error!)")
}
}
func signalClient(_ signalClient: SignalingClient, didReceiveConfig config: RTCConfiguration) {
Log.info("Received peer connection configuration - ICE servers: \(config.iceServers)")
self.webRTCClient?.setupPeerConnection(rtcConfiguration: config)
}
func signalClient(_ signalClient: SignalingClient, didReceiveRemoteSdp sdp: RTCSessionDescription) {
var sdpTypeStr : String = ""
switch sdp.type {
case RTCSdpType.answer:
sdpTypeStr = "answer"
case RTCSdpType.offer:
sdpTypeStr = "offer"
case RTCSdpType.prAnswer:
sdpTypeStr = "prAnswer"
case RTCSdpType.rollback:
sdpTypeStr = "rollback"
@unknown default:
sdpTypeStr = "unknown"
}
Log.info("Received remote sdp. Type=\(sdpTypeStr)")
Log.info(sdp.sdp)
if self.webRTCClient!.hasPeerConnnection() {
self.webRTCClient!.handleRemoteSdp(remoteSdp: sdp) { (error) in
self.hasRemoteSdp = true
// If we get an offer from the streamer we send an answer back
if sdp.type == RTCSdpType.offer {
self.signalClientSendAnswer(signalClient)
}
else {
Log.debug("We only support replying to offer, but we got \(sdpTypeStr)")
}
}
} else {
Log.debug("WebRTC peer connection not setup yet - cannot handle remote sdp.")
}
}
func signalClient(_ signalClient: SignalingClient, didReceiveCandidate candidate: RTCIceCandidate) {
Log.info("Received remote candidate - \(candidate.sdp)")
if self.webRTCClient!.hasPeerConnnection() {
self.webRTCClient!.handleRemoteCandidate(remoteCandidate: candidate) { error in
self.remoteCandidateCount += 1
}
} else {
Log.debug("WebRTC peer connection not setup yet - cannot handle remote candidate")
}
}
func signalClient(_ signalClient: SignalingClient, didReceiveStreamerList streamerList: Array<String>) {
if signalClient.isReconnecting {
if streamerList.contains(self.subscribedStreamer) {
// If we're reconnecting and the previously subscribed stream has come back, resubscribe to it
signalClient.isReconnecting = false
self.reconnectAttempt = 0
signalClient.subscribe(self.subscribedStreamer)
} else if self.reconnectAttempt < self.maxReconnectAttempts {
// Our previous stream hasn't come back, wait 2 seconds and request an updated stream list
self.reconnectAttempt += 1
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
signalClient.sendRequestStreamerList()
}
} else {
// We've exhausted our reconnect attempts, return to main menu
self.reconnectAttempt = 0
self.delegate?.streamingConnection(self, exitWithError: NSError(domain: "", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to reconnect to \(subscribedStreamer) after \(maxReconnectAttempts) attempts"]))
}
} else {
if streamerList.count == 0 {
self.delegate?.streamingConnection(self, exitWithError: NSError(domain: "", code: 1, userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("error-nostream", value:"No stream connected.", comment: "Error message.")]))
} else if streamerList.count == 1 {
// If we only have a single streamer, no need to show the selection dialogue
self.subscribedStreamer = streamerList[0]
signalClient.subscribe(streamerList[0])
} else if streamerList.count > 1 {
// Otherwise make sure we have more than 1 and display the picker
self.delegate?.streamingConnection(self, requestStreamerSelectionWithStreamers: streamerList) { (selectedStreamer) in
self.subscribedStreamer = selectedStreamer
signalClient.subscribe(selectedStreamer)
}
}
}
}
func signalClientSendAnswer(_ signalClient: SignalingClient){
if self.webRTCClient!.hasPeerConnnection() {
Log.info("Sending answer sdp")
self.webRTCClient!.answer { (localSdp) in
let mungedSDP : RTCSessionDescription = self.addSessionIDToSDP(localSdp)
Log.info(mungedSDP.sdp)
self.hasLocalSdp = true
signalClient.send(sdp: mungedSDP)
}
} else {
Log.debug("WebRTC peer connection not setup yet - cannot handle sending answer.")
}
}
func addSessionIDToSDP(_ inSDP: RTCSessionDescription) -> RTCSessionDescription {
// Munge the o= line of the SDP to add a unique identifier for LiveLink app
var sdpStr : String = inSDP.sdp
let releaseVersionNumber: String? = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
let buildVersionNumber: String? = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
if let relNum = releaseVersionNumber, let buildNum = buildVersionNumber {
// Make a string that is like: s=LiveLink/1.3.2(130)
let replacementStr : String = "s=LiveLink/\(relNum)/(\(buildNum))"
// Replace on the "s=-" which is session id line in the SDP
sdpStr = sdpStr.replacingOccurrences(of: "s=-", with: replacementStr)
}
return RTCSessionDescription(type: inSDP.type, sdp: sdpStr)
}
}
extension WebRTCStreamingConnection: WebRTCClientDelegate {
func webRTCClient(_ client: WebRTCClient, onStartReceiveVideo video: RTCVideoTrack) {
self.rtcVideoTrack = video
self.attachVideoTrack()
self.delegate?.streamingConnectionDidConnect(self)
}
func webRTCClient(_ client: WebRTCClient, didDiscoverLocalCandidate candidate: RTCIceCandidate) {
Log.info("discovered local candidate")
self.localCandidateCount += 1
self.signalClient?.send(candidate: candidate)
}
func webRTCClient(_ client: WebRTCClient, didChangeConnectionState state: RTCIceConnectionState) {
self.webRTCClientState = state
switch state {
case .connected:
// WebRTC connection is "connected" so try to show remote video track if we got one some time during the connection
// IMPORTANT: Even though we do the same in onStartReceiveVideo above, this one actually makes the video show up due
// peer connection needing to be connected before video can reasonably be displayed - the other callback is more useful
// if video is added later on into the call.
self.rtcVideoTrack = self.webRTCClient?.getRemoteVideoTrack()
self.attachVideoTrack()
case .disconnected:
self.delegate?.streamingConnection(self, didDisconnectWithError: nil)
case .failed:
self.delegate?.streamingConnection(self, didDisconnectWithError: NSError(domain: "", code: 1, userInfo: [NSLocalizedDescriptionKey : NSLocalizedString("error-connectfailed", value:"Failed to connect.", comment: "Error message.") ] ))
default:
break
}
Log.info("WebRTC status: \(state.description.capitalized)")
}
func webRTCClient(_ client: WebRTCClient, didReceiveData data: Data) {
if(data.count > 0) {
let payloadTypeInt : UInt8 = data[0]
if let payloadType = PixelStreamingToClientMessage(rawValue: payloadTypeInt) {
switch payloadType {
case .VideoEncoderAvgQP:
let qp : String? = String(data: data.dropFirst(), encoding: .utf16LittleEndian)
//Log.info("Quality = \(qp ?? "N/A")")
case .Command:
let command: String? = String(data: data.dropFirst(), encoding: .utf16LittleEndian)
Log.info("command = \(command ?? "NULL")")
if let commandData = command?.data(using: .utf8) {
do {
let commandJson: PixelStreamingToClientCommand = try JSONDecoder().decode(PixelStreamingToClientCommand.self, from: commandData)
if commandJson.command == "onScreenKeyboard" {
let showKeyboardJson: PixelStreamingToClientShowOnScreenKeyboardCommand = try JSONDecoder().decode(PixelStreamingToClientShowOnScreenKeyboardCommand.self, from: commandData)
if showKeyboardJson.showOnScreenKeyboard, let contents = showKeyboardJson.contents {
self.delegate?.streamingConnection(self, requestsTextEditWithContents: contents) { (success, newContents) in
guard let enteredContents = newContents else { return }
if success {
self.keyboardControls?.submitString(enteredContents)
}
}
}
}
} catch {
Log.error("An error occurred parsing the command `\(command ?? "<invalid>")` : \(error.localizedDescription)")
}
}
case .GamepadResponse:
let response: String? = String(data: data.dropFirst(), encoding: .utf16LittleEndian)
Log.info("response = \(response ?? "NULL")")
if let responseData = response?.data(using: .utf8) {
do {
let responseJson: PixelStreamingToClientGamepadResponse = try JSONDecoder().decode(PixelStreamingToClientGamepadResponse.self, from: responseData)
self.delegate?.streamingConnection(self, receivedGamepadResponse: responseJson.controllerId)
} catch {
Log.error("An error occurred parsing the response `\(response ?? "<invalid>")` : \(error.localizedDescription)")
}
}
case .QualityControlOwnership:
// Log quality control ownership (are we the controller or not?)
let isQualityController : Bool = (data[1] == 1) ? true : false
Log.info("VCam is quality controller: \(isQualityController ? "true" : "false")")
if !isQualityController {
self.webRTCClient?.sendRequestQualityControl()
self.webRTCClient?.sendRequestKeyFrame()
}
case .Response:
let numResponses : String? = String(data: data.dropFirst(), encoding: .utf16LittleEndian)
self.webRTCStats?.processARKitResponse(numResponses: UInt16(numResponses ?? "") ?? 0)
case .FreezeFrame:
fallthrough
case .UnfreezeFrame:
fallthrough
case .LatencyTest:
fallthrough
case .InitialSettings:
// Do nothing with initial settings, but use it to send device resolution as this is a convenient time as we are guaranteed datachannel is working
self.webRTCClient?.sendDeviceResolution()
fallthrough
case .FileExtension:
fallthrough
case .FileMimeType:
fallthrough
case .FileContents:
print("Skipping payload type \(payloadType) - we have no implementation for it.")
}
}
}
}
}