From 8d6ce5349c4bfa94033b18a988c40f013f37b7d0 Mon Sep 17 00:00:00 2001 From: sirknightj Date: Mon, 29 Sep 2025 18:44:12 -0700 Subject: [PATCH 1/6] WebRTC ingestion support (master) --- .../project.pbxproj | 12 +- .../ChannelConfigurationViewController.swift | 34 ++++- Swift/KVSiOSApp/SignalingClient.swift | 5 + Swift/KVSiOSApp/VideoViewController.swift | 137 ++++++++++++++++-- Swift/KVSiOSApp/VideoViewController.xib | 20 +-- Swift/KVSiOSApp/WebRTCClient.swift | 4 +- Swift/Podfile | 1 + 7 files changed, 177 insertions(+), 36 deletions(-) diff --git a/Swift/AWSKinesisVideoWebRTCDemoApp.xcodeproj/project.pbxproj b/Swift/AWSKinesisVideoWebRTCDemoApp.xcodeproj/project.pbxproj index a425a93..1136c29 100644 --- a/Swift/AWSKinesisVideoWebRTCDemoApp.xcodeproj/project.pbxproj +++ b/Swift/AWSKinesisVideoWebRTCDemoApp.xcodeproj/project.pbxproj @@ -387,6 +387,7 @@ 17FC2F7B1E258FC500174015 = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1020; + ProvisioningStyle = Automatic; }; 703513E32399DA0400376B66 = { CreatedOnToolsVersion = 11.2; @@ -469,6 +470,7 @@ "${BUILT_PRODUCTS_DIR}/AWSCore/AWSCore.framework", "${BUILT_PRODUCTS_DIR}/AWSKinesisVideo/AWSKinesisVideo.framework", "${BUILT_PRODUCTS_DIR}/AWSKinesisVideoSignaling/AWSKinesisVideoSignaling.framework", + "${BUILT_PRODUCTS_DIR}/AWSKinesisVideoWebRTCStorage/AWSKinesisVideoWebRTCStorage.framework", "${BUILT_PRODUCTS_DIR}/AWSMobileClient/AWSMobileClient.framework", "${BUILT_PRODUCTS_DIR}/CommonCryptoModule/CommonCryptoModule.framework", "${PODS_ROOT}/GoogleWebRTC/Frameworks/frameworks/WebRTC.framework", @@ -482,6 +484,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSCore.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSKinesisVideo.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSKinesisVideoSignaling.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSKinesisVideoWebRTCStorage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSMobileClient.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CommonCryptoModule.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework", @@ -527,6 +530,7 @@ "${BUILT_PRODUCTS_DIR}/AWSCore/AWSCore.framework", "${BUILT_PRODUCTS_DIR}/AWSKinesisVideo/AWSKinesisVideo.framework", "${BUILT_PRODUCTS_DIR}/AWSKinesisVideoSignaling/AWSKinesisVideoSignaling.framework", + "${BUILT_PRODUCTS_DIR}/AWSKinesisVideoWebRTCStorage/AWSKinesisVideoWebRTCStorage.framework", "${BUILT_PRODUCTS_DIR}/AWSMobileClient/AWSMobileClient.framework", "${BUILT_PRODUCTS_DIR}/CommonCryptoModule/CommonCryptoModule.framework", "${PODS_ROOT}/GoogleWebRTC/Frameworks/frameworks/WebRTC.framework", @@ -540,6 +544,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSCore.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSKinesisVideo.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSKinesisVideoSignaling.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSKinesisVideoWebRTCStorage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSMobileClient.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CommonCryptoModule.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework", @@ -659,8 +664,8 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = YES; - "ARCHS[sdk=iphonesimulator*]" = x86_64; "ARCHS[sdk=iphoneos*]" = "$(ARCHS_STANDARD)"; + "ARCHS[sdk=iphonesimulator*]" = x86_64; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -723,8 +728,8 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = YES; - "ARCHS[sdk=iphonesimulator*]" = x86_64; "ARCHS[sdk=iphoneos*]" = "$(ARCHS_STANDARD)"; + "ARCHS[sdk=iphonesimulator*]" = x86_64; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -784,6 +789,8 @@ baseConfigurationReference = BDB1499CD0982C181C7DDE9E /* Pods-AWSKinesisVideoWebRTCDemoApp.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=*]" = ""; @@ -825,6 +832,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.kinesisvideo.KVSApp1; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 4.0; }; name = Debug; diff --git a/Swift/KVSiOSApp/ChannelConfigurationViewController.swift b/Swift/KVSiOSApp/ChannelConfigurationViewController.swift index d6a22af..0d55bb9 100755 --- a/Swift/KVSiOSApp/ChannelConfigurationViewController.swift +++ b/Swift/KVSiOSApp/ChannelConfigurationViewController.swift @@ -2,6 +2,7 @@ import AWSCore import AWSCognitoIdentityProvider import AWSKinesisVideo import AWSKinesisVideoSignaling +import AWSKinesisVideoWebRTCStorage import AWSMobileClient import Foundation import WebRTC @@ -256,6 +257,18 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate let RTCIceServersList = getIceCandidates(channelARN: channelARN!, endpoint: httpsEndpoint!, regionType: awsRegionType, clientId: localSenderId) webRTCClient = WebRTCClient(iceServers: RTCIceServersList, isAudioOn: sendAudioEnabled, resolution: selectedResolution) webRTCClient!.delegate = self + + guard !usingMediaServer || endpoints["WEBRTC"] != nil else { + print("connectAsRole IllegalState! WEBRTC endpoint is required for WebRTC ingestion") + return + } + if usingMediaServer { + let webRTCEndpoint: String = endpoints["WEBRTC"]!! + let webRTCStorageConfiguration = AWSServiceConfiguration(region: awsRegionType, + endpoint: AWSEndpoint(urlString: webRTCEndpoint), + credentialsProvider: getCredentialsProvider()) + AWSKinesisVideoWebRTCStorage.register(with: webRTCStorageConfiguration!, forKey: awsKinesisVideoKey) + } // Connect to signalling channel with wss endpoint print("Connecting to web socket from channel config") @@ -267,7 +280,7 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate let seconds = 2.0 DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { self.updateConnectionLabel() - self.vc = VideoViewController(webRTCClient: self.webRTCClient!, signalingClient: self.signalingClient!, localSenderClientID: self.localSenderId, isMaster: self.isMaster, mediaServerEndPoint: endpoints["WEBRTC"] ?? nil) + self.vc = VideoViewController(webRTCClient: self.webRTCClient!, signalingClient: self.signalingClient!, localSenderClientID: self.localSenderId, isMaster: self.isMaster, signalingChannelArn: usingMediaServer ? channelARN : nil) self.present(self.vc!, animated: true, completion: nil) } } @@ -499,6 +512,12 @@ extension ChannelConfigurationViewController: SignalClientDelegate { remoteSenderClientId = senderClientId } setRemoteSenderClientId() + + // Mark that offer was received to stop storage session retries + if sdp.type == .offer { + vc?.markOfferReceived() + } + webRTCClient!.set(remoteSdp: sdp, clientId: senderClientId) { _ in print("Setting remote sdp and sending answer.") self.vc!.sendAnswer(recipientClientID: self.remoteSenderClientId!) @@ -529,14 +548,23 @@ extension ChannelConfigurationViewController: WebRTCClientDelegate { switch state { case .connected, .completed: print("WebRTC connected/completed state") + DispatchQueue.main.async { + self.vc?.showToast(message: "WebRTC Connected", length: "short") + } case .disconnected: print("WebRTC disconnected state") + DispatchQueue.main.async { + self.vc?.showToast(message: "WebRTC Disconnected", length: "short") + } + case .failed: + print("WebRTC failed state") + DispatchQueue.main.async { + self.vc?.showToast(message: "WebRTC Connection Failed", length: "long") + } case .new: print("WebRTC new state") case .checking: print("WebRTC checking state") - case .failed: - print("WebRTC failed state") case .closed: print("WebRTC closed state") case .count: diff --git a/Swift/KVSiOSApp/SignalingClient.swift b/Swift/KVSiOSApp/SignalingClient.swift index 2ac192f..0386273 100755 --- a/Swift/KVSiOSApp/SignalingClient.swift +++ b/Swift/KVSiOSApp/SignalingClient.swift @@ -104,6 +104,11 @@ extension SignalingClient: WebSocketDelegate { } func websocketDidReceiveMessage(socket _: WebSocketClient, text: String) { + guard !text.isEmpty else { + debugPrint("Ignoring empty WebSocket message") + return + } + debugPrint("Additional signaling messages \(text)") var parsedMessage: Message? diff --git a/Swift/KVSiOSApp/VideoViewController.swift b/Swift/KVSiOSApp/VideoViewController.swift index c7e6432..517884f 100755 --- a/Swift/KVSiOSApp/VideoViewController.swift +++ b/Swift/KVSiOSApp/VideoViewController.swift @@ -1,31 +1,40 @@ import UIKit import AWSKinesisVideo +import AWSKinesisVideoWebRTCStorage import WebRTC class VideoViewController: UIViewController { @IBOutlet var localVideoView: UIView? - @IBOutlet var joinStorageButton: UIButton? private let webRTCClient: WebRTCClient private let signalingClient: SignalingClient private let localSenderClientID: String private let isMaster: Bool + private let signalingChannelArn: String? + private var hasReceivedOffer = false + private var storageSessionAttempts: [Date] = [] - init(webRTCClient: WebRTCClient, signalingClient: SignalingClient, localSenderClientID: String, isMaster: Bool, mediaServerEndPoint: String?) { + init(webRTCClient: WebRTCClient, signalingClient: SignalingClient, localSenderClientID: String, isMaster: Bool, signalingChannelArn: String?) { self.webRTCClient = webRTCClient self.signalingClient = signalingClient self.localSenderClientID = localSenderClientID self.isMaster = isMaster + self.signalingChannelArn = signalingChannelArn super.init(nibName: String(describing: VideoViewController.self), bundle: Bundle.main) + let isIngestMedia: Bool = self.signalingChannelArn != nil + if !isMaster { // In viewer mode send offer once connection is established webRTCClient.offer { sdp in self.signalingClient.sendOffer(rtcSdp: sdp, senderClientid: self.localSenderClientID) } } - if mediaServerEndPoint == nil { - self.joinStorageButton?.isHidden = true + + if isIngestMedia { + DispatchQueue.global(qos: .background).async { + self.joinStorageSessionWithRetry() + } } } @@ -40,26 +49,33 @@ class VideoViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + let isIngestMode = signalingChannelArn != nil + #if arch(arm64) // Using metal (arm64 only) - let localRenderer = RTCMTLVideoView(frame: localVideoView?.frame ?? CGRect.zero) + let localRenderer = RTCMTLVideoView(frame: isIngestMode ? view.frame : (localVideoView?.frame ?? CGRect.zero)) let remoteRenderer = RTCMTLVideoView(frame: view.frame) localRenderer.videoContentMode = .scaleAspectFill remoteRenderer.videoContentMode = .scaleAspectFill #else // Using OpenGLES for the rest - let localRenderer = RTCEAGLVideoView(frame: localVideoView?.frame ?? CGRect.zero) + let localRenderer = RTCEAGLVideoView(frame: isIngestMode ? view.frame : (localVideoView?.frame ?? CGRect.zero)) let remoteRenderer = RTCEAGLVideoView(frame: view.frame) #endif webRTCClient.startCaptureLocalVideo(renderer: localRenderer) - webRTCClient.renderRemoteVideo(to: remoteRenderer) - - if let localVideoView = self.localVideoView { - embedView(localRenderer, into: localVideoView) + + if isIngestMode { + embedView(localRenderer, into: view) + view.sendSubview(toBack: localRenderer) + } else { + webRTCClient.renderRemoteVideo(to: remoteRenderer) + if let localVideoView = self.localVideoView { + embedView(localRenderer, into: localVideoView) + } + embedView(remoteRenderer, into: view) + view.sendSubview(toBack: remoteRenderer) } - embedView(remoteRenderer, into: view) - view.sendSubview(toBack: remoteRenderer) } private func embedView(_ view: UIView, into containerView: UIView) { @@ -83,10 +99,53 @@ class VideoViewController: UIViewController { dismiss(animated: true) } - @IBAction func joinStorageSession(_: Any) { - print("button pressed") - joinStorageButton?.isHidden = true + func joinStorageSessionWithRetry() { + guard let signalingChannelArn = self.signalingChannelArn else { + print("joinStorageSessionWithRetry IllegalState! ARN cannot be nil") + return + } + // If we already received an offer, stop retrying + if hasReceivedOffer { + print("SDP offer received, stopping storage session retries") + return + } + + // Clean up attempts older than 10 minutes + let tenMinutesAgo = Date().addingTimeInterval(-600) + storageSessionAttempts = storageSessionAttempts.filter { $0 > tenMinutesAgo } + + // Check if we've exceeded 5 attempts in the last 10 minutes + if storageSessionAttempts.count >= 5 { + print("Too many storage session attempts (5) within 10 minutes. Stopping retries.") + return + } + + // Record this attempt + storageSessionAttempts.append(Date()) + + let webrtcStorageClient = AWSKinesisVideoWebRTCStorage(forKey: awsKinesisVideoKey) + let joinStorageSessionRequest = AWSKinesisVideoWebRTCStorageJoinStorageSessionInput() + joinStorageSessionRequest?.channelArn = signalingChannelArn + + print("Calling JoinStorageSession with ARN: \(signalingChannelArn) (attempt \(storageSessionAttempts.count))") + + webrtcStorageClient.joinSession(joinStorageSessionRequest!).continueWith(block: { (task) -> Void in + if let error = task.error { + print("Error joining storage session: \(error)") + } else { + print("Joined storage session!") + } + + // Retry after 6 seconds if no offer received yet + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 6.0) { + self.joinStorageSessionWithRetry() + } + }) + } + + func markOfferReceived() { + hasReceivedOffer = true } func sendAnswer(recipientClientID: String) { @@ -96,4 +155,52 @@ class VideoViewController: UIViewController { self.webRTCClient.updatePeerConnectionAndHandleIceCandidates(clientId: recipientClientID) } } + + func showToast(message: String, length: String = "short") { + guard length == "short" || length == "long" else { + print("showToast: Invalid argument - length must either be short or long") + return + } + + let durationSec = length == "short" ? 2.0 : 3.5 + let padding: CGFloat = 12 + + let toastContainer = UIView() + toastContainer.backgroundColor = UIColor.black.withAlphaComponent(0.8) + toastContainer.layer.cornerRadius = 10 + toastContainer.translatesAutoresizingMaskIntoConstraints = false + toastContainer.alpha = 0.0 + + let toastLabel = UILabel() + toastLabel.text = message + toastLabel.textAlignment = .center + toastLabel.textColor = UIColor.white + toastLabel.font = UIFont.systemFont(ofSize: 14) + toastLabel.numberOfLines = 0 + toastLabel.translatesAutoresizingMaskIntoConstraints = false + + toastContainer.addSubview(toastLabel) + self.view.addSubview(toastContainer) + + NSLayoutConstraint.activate([ + toastLabel.topAnchor.constraint(equalTo: toastContainer.topAnchor, constant: padding), + toastLabel.bottomAnchor.constraint(equalTo: toastContainer.bottomAnchor, constant: -padding), + toastLabel.leadingAnchor.constraint(equalTo: toastContainer.leadingAnchor, constant: padding), + toastLabel.trailingAnchor.constraint(equalTo: toastContainer.trailingAnchor, constant: -padding), + toastContainer.leadingAnchor.constraint(greaterThanOrEqualTo: self.view.leadingAnchor, constant: 20), + toastContainer.trailingAnchor.constraint(lessThanOrEqualTo: self.view.trailingAnchor, constant: -20), + toastContainer.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + toastContainer.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -50) + ]) + + UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { + toastContainer.alpha = 1.0 + }) { _ in + UIView.animate(withDuration: 0.5, delay: durationSec, options: .curveEaseIn, animations: { + toastContainer.alpha = 0.0 + }) { _ in + toastContainer.removeFromSuperview() + } + } + } } diff --git a/Swift/KVSiOSApp/VideoViewController.xib b/Swift/KVSiOSApp/VideoViewController.xib index a81c305..e690e19 100755 --- a/Swift/KVSiOSApp/VideoViewController.xib +++ b/Swift/KVSiOSApp/VideoViewController.xib @@ -1,23 +1,22 @@ - - + + - + - - + - + - diff --git a/Swift/KVSiOSApp/WebRTCClient.swift b/Swift/KVSiOSApp/WebRTCClient.swift index 7b4d38f..22db4f4 100755 --- a/Swift/KVSiOSApp/WebRTCClient.swift +++ b/Swift/KVSiOSApp/WebRTCClient.swift @@ -146,8 +146,10 @@ final class WebRTCClient: NSObject { peerConnection.setRemoteDescription(remoteSdp, completionHandler: completion) if remoteSdp.type == RTCSdpType.answer { print("Received answer for client ID: \(clientId)") - updatePeerConnectionAndHandleIceCandidates(clientId: clientId) + } else { + print("Received offer from remote") } + updatePeerConnectionAndHandleIceCandidates(clientId: clientId) } func checkAndAddIceCandidate(remoteCandidate: RTCIceCandidate, clientId: String) { diff --git a/Swift/Podfile b/Swift/Podfile index 8df3312..8ac0d35 100755 --- a/Swift/Podfile +++ b/Swift/Podfile @@ -9,6 +9,7 @@ target 'AWSKinesisVideoWebRTCDemoApp' do pod 'CommonCryptoModule' pod 'AWSKinesisVideo' pod 'AWSKinesisVideoSignaling' + pod 'AWSKinesisVideoWebRTCStorage' pod 'GoogleWebRTC', '~> 1.1' pod 'Starscream', '~> 3.0' target 'AWSKinesisVideoWebRTCDemoAppUITests' do From 688b8c923244db61aae12ccb2555691f839d9226 Mon Sep 17 00:00:00 2001 From: sirknightj Date: Thu, 2 Oct 2025 15:58:33 -0700 Subject: [PATCH 2/6] WebRTC ingestion support (joinStorageSession and joinStorageSessionAsViewer), new send video switch, toast notification for WebRTC connected, remove manual JoinStorageSession button, fullscreen ingestion view, updated ice candidate queue logic --- .../ChannelConfigurationViewController.swift | 35 ++++++-- Swift/KVSiOSApp/Main.storyboard | 60 +++++++++---- Swift/KVSiOSApp/VideoViewController.swift | 90 +++++++++++++------ Swift/KVSiOSApp/WebRTCClient.swift | 30 ++++++- 4 files changed, 155 insertions(+), 60 deletions(-) diff --git a/Swift/KVSiOSApp/ChannelConfigurationViewController.swift b/Swift/KVSiOSApp/ChannelConfigurationViewController.swift index 0d55bb9..da640b3 100755 --- a/Swift/KVSiOSApp/ChannelConfigurationViewController.swift +++ b/Swift/KVSiOSApp/ChannelConfigurationViewController.swift @@ -33,6 +33,7 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate // variables controlled by UI var sendAudioEnabled: Bool = true + var sendVideoEnabled: Bool = true var isMaster: Bool = false var signalingConnected: Bool = false var selectedResolution: VideoResolution = .resolution720p @@ -53,6 +54,7 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate @IBOutlet var clientID: UITextField! @IBOutlet var regionName: UITextField! @IBOutlet var isAudioEnabled: UISwitch! + @IBOutlet var isVideoEnabled: UISwitch! @IBOutlet var resolutionButton: UIButton! // Connect Buttons @@ -119,6 +121,14 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate } } + @IBAction func videoStateChanged(sender: UISwitch!) { + if sender.isOn { + self.sendVideoEnabled = true + } else { + self.sendVideoEnabled = false + } + } + @IBAction func resolutionButtonTapped(_ sender: UIButton) { let alert = UIAlertController(title: "Select Resolution", message: nil, preferredStyle: .actionSheet) @@ -225,16 +235,22 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate } } // check whether signalling channel will save its recording to a stream - // only applies for master - var usingMediaServer : Bool = false - if self.isMaster { - usingMediaServer = isUsingMediaServer(channelARN: channelARN!, channelName: channelNameValue) - // Make sure that audio is enabled if ingesting webrtc connection - if(usingMediaServer && !self.sendAudioEnabled) { - popUpError(title: "Invalid Configuration", message: "Audio must be enabled to use MediaServer") + var usingMediaServer: Bool = isUsingMediaServer(channelARN: channelARN!, channelName: channelNameValue) + // Make sure that audio is enabled if ingesting webrtc connection + if(usingMediaServer) { + if (self.isMaster && (!self.isAudioEnabled.isOn || !self.isVideoEnabled.isOn)) { + // Master mode: Both audio and video required + popUpError(title: "Invalid Configuration", message: "Video and audio must be enabled for WebRTC ingestion master") return + } else { + // Viewer mode: Video not allowed, audio optional + if (self.isVideoEnabled.isOn) { + popUpError(title: "Invalid Configuration", message: "Video is not allowed for WebRTC ingestion viewer") + return + } } } + // get signalling channel endpoints let endpoints = getSignallingEndpoints(channelARN: channelARN!, region: awsRegionValue, isMaster: self.isMaster, useMediaServer: usingMediaServer) //// Ensure that the WebSocket (WSS) endpoint is available; WebRTC requires a valid signaling endpoint. @@ -255,7 +271,7 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate service: .KinesisVideo, url: URL(string: endpoints["HTTPS"]!!)) let RTCIceServersList = getIceCandidates(channelARN: channelARN!, endpoint: httpsEndpoint!, regionType: awsRegionType, clientId: localSenderId) - webRTCClient = WebRTCClient(iceServers: RTCIceServersList, isAudioOn: sendAudioEnabled, resolution: selectedResolution) + webRTCClient = WebRTCClient(iceServers: RTCIceServersList, isAudioOn: sendAudioEnabled, isVideoOn: sendVideoEnabled, resolution: selectedResolution) webRTCClient!.delegate = self guard !usingMediaServer || endpoints["WEBRTC"] != nil else { @@ -280,7 +296,7 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate let seconds = 2.0 DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { self.updateConnectionLabel() - self.vc = VideoViewController(webRTCClient: self.webRTCClient!, signalingClient: self.signalingClient!, localSenderClientID: self.localSenderId, isMaster: self.isMaster, signalingChannelArn: usingMediaServer ? channelARN : nil) + self.vc = VideoViewController(webRTCClient: self.webRTCClient!, signalingClient: self.signalingClient!, localSenderClientID: self.localSenderId, isMaster: self.isMaster, signalingChannelArn: usingMediaServer ? channelARN : nil, isVideoEnabled: self.sendVideoEnabled) self.present(self.vc!, animated: true, completion: nil) } } @@ -361,6 +377,7 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate } } }).waitUntilFinished() + print("\(channelARN) configured for ingestion? \(usingMediaServer)") return usingMediaServer } diff --git a/Swift/KVSiOSApp/Main.storyboard b/Swift/KVSiOSApp/Main.storyboard index e11ca83..8ac3090 100755 --- a/Swift/KVSiOSApp/Main.storyboard +++ b/Swift/KVSiOSApp/Main.storyboard @@ -556,8 +556,8 @@ - - + + @@ -569,7 +569,7 @@ - + @@ -578,7 +578,7 @@ - + @@ -587,7 +587,7 @@ - - + + - + + + + + - + @@ -647,12 +665,9 @@ - - - @@ -685,6 +700,7 @@ + @@ -709,7 +725,7 @@ - + @@ -717,7 +733,7 @@ - + @@ -755,8 +771,14 @@ + + + + + + diff --git a/Swift/KVSiOSApp/VideoViewController.swift b/Swift/KVSiOSApp/VideoViewController.swift index 517884f..f735cc2 100755 --- a/Swift/KVSiOSApp/VideoViewController.swift +++ b/Swift/KVSiOSApp/VideoViewController.swift @@ -11,20 +11,25 @@ class VideoViewController: UIViewController { private let localSenderClientID: String private let isMaster: Bool private let signalingChannelArn: String? + private let isVideoEnabled: Bool private var hasReceivedOffer = false - private var storageSessionAttempts: [Date] = [] + private var storageSessionAttempts: [Date] - init(webRTCClient: WebRTCClient, signalingClient: SignalingClient, localSenderClientID: String, isMaster: Bool, signalingChannelArn: String?) { + init(webRTCClient: WebRTCClient, signalingClient: SignalingClient, localSenderClientID: String, isMaster: Bool, signalingChannelArn: String?, isVideoEnabled: Bool = true) { self.webRTCClient = webRTCClient self.signalingClient = signalingClient self.localSenderClientID = localSenderClientID self.isMaster = isMaster self.signalingChannelArn = signalingChannelArn + self.isVideoEnabled = isVideoEnabled + self.storageSessionAttempts = [] super.init(nibName: String(describing: VideoViewController.self), bundle: Bundle.main) let isIngestMedia: Bool = self.signalingChannelArn != nil + print("isIngestMedia? \(isIngestMedia)") + print("role: \(isMaster ? "master" : "viewer")") - if !isMaster { + if !isIngestMedia && !isMaster { // In viewer mode send offer once connection is established webRTCClient.offer { sdp in self.signalingClient.sendOffer(rtcSdp: sdp, senderClientid: self.localSenderClientID) @@ -63,19 +68,24 @@ class VideoViewController: UIViewController { let remoteRenderer = RTCEAGLVideoView(frame: view.frame) #endif - webRTCClient.startCaptureLocalVideo(renderer: localRenderer) - - if isIngestMode { - embedView(localRenderer, into: view) - view.sendSubview(toBack: localRenderer) - } else { - webRTCClient.renderRemoteVideo(to: remoteRenderer) + if (!isIngestMode || !self.isMaster) && isVideoEnabled { + webRTCClient.startCaptureLocalVideo(renderer: localRenderer) + } + + // Always set up remote video rendering + webRTCClient.renderRemoteVideo(to: remoteRenderer) + + // Only show local video view if we're actually capturing local video + if (!isIngestMode || !self.isMaster) && isVideoEnabled { if let localVideoView = self.localVideoView { embedView(localRenderer, into: localVideoView) } - embedView(remoteRenderer, into: view) - view.sendSubview(toBack: remoteRenderer) + } else if let localVideoView = self.localVideoView { + localVideoView.isHidden = true } + + embedView(remoteRenderer, into: view) + view.sendSubview(toBack: remoteRenderer) } private func embedView(_ view: UIView, into containerView: UIView) { @@ -115,8 +125,8 @@ class VideoViewController: UIViewController { let tenMinutesAgo = Date().addingTimeInterval(-600) storageSessionAttempts = storageSessionAttempts.filter { $0 > tenMinutesAgo } - // Check if we've exceeded 5 attempts in the last 10 minutes - if storageSessionAttempts.count >= 5 { + // Check if we've exceeded 2 attempts in the last 10 minutes + if storageSessionAttempts.count >= 2 { print("Too many storage session attempts (5) within 10 minutes. Stopping retries.") return } @@ -125,23 +135,45 @@ class VideoViewController: UIViewController { storageSessionAttempts.append(Date()) let webrtcStorageClient = AWSKinesisVideoWebRTCStorage(forKey: awsKinesisVideoKey) - let joinStorageSessionRequest = AWSKinesisVideoWebRTCStorageJoinStorageSessionInput() - joinStorageSessionRequest?.channelArn = signalingChannelArn - - print("Calling JoinStorageSession with ARN: \(signalingChannelArn) (attempt \(storageSessionAttempts.count))") - webrtcStorageClient.joinSession(joinStorageSessionRequest!).continueWith(block: { (task) -> Void in - if let error = task.error { - print("Error joining storage session: \(error)") - } else { - print("Joined storage session!") - } + if self.isMaster { + let joinStorageSessionRequest = AWSKinesisVideoWebRTCStorageJoinStorageSessionInput() + joinStorageSessionRequest?.channelArn = signalingChannelArn - // Retry after 6 seconds if no offer received yet - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 6.0) { - self.joinStorageSessionWithRetry() - } - }) + print("Calling JoinStorageSession with ARN: \(signalingChannelArn) (attempt \(storageSessionAttempts.count))") + + webrtcStorageClient.joinSession(joinStorageSessionRequest!).continueWith(block: { (task) -> Void in + if let error = task.error { + print("Error joining storage session: \(error)") + } else { + print("Joined storage session!") + } + + // Retry after 6 seconds if no offer received yet + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 6.0) { + self.joinStorageSessionWithRetry() + } + }) + } else { + let joinStorageSessionAsViewerRequest = AWSKinesisVideoWebRTCStorageJoinStorageSessionAsViewerInput() + joinStorageSessionAsViewerRequest?.channelArn = signalingChannelArn + joinStorageSessionAsViewerRequest?.clientId = self.localSenderClientID + + print("Calling JoinStorageSessionAsViewer with ARN: \(signalingChannelArn) and clientId: \(self.localSenderClientID) (attempt \(storageSessionAttempts.count))") + + webrtcStorageClient.joinSession(asViewer: joinStorageSessionAsViewerRequest!).continueWith(block: { (task) -> Void in + if let error = task.error { + print("Error joining storage session: \(error)") + } else { + print("Joined storage session!") + } + + // Retry after 6 seconds if no offer received yet + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 6.0) { + self.joinStorageSessionWithRetry() + } + }) + } } func markOfferReceived() { diff --git a/Swift/KVSiOSApp/WebRTCClient.swift b/Swift/KVSiOSApp/WebRTCClient.swift index 22db4f4..5b93b17 100755 --- a/Swift/KVSiOSApp/WebRTCClient.swift +++ b/Swift/KVSiOSApp/WebRTCClient.swift @@ -30,11 +30,12 @@ final class WebRTCClient: NSObject { private var remoteDataChannel: RTCDataChannel? private var constructedIceServers: [RTCIceServer]? private var selectedResolution: VideoResolution + private var remoteRenderer: RTCVideoRenderer? private var peerConnectionFoundMap = [String: RTCPeerConnection]() private var pendingIceCandidatesMap = [String: Set]() - required init(iceServers: [RTCIceServer], isAudioOn: Bool, resolution: VideoResolution = .resolution720p) { + required init(iceServers: [RTCIceServer], isAudioOn: Bool, isVideoOn: Bool = true, resolution: VideoResolution = .resolution720p) { self.selectedResolution = resolution let config = RTCConfiguration() @@ -55,7 +56,9 @@ final class WebRTCClient: NSObject { if (isAudioOn) { createLocalAudioStream() } + if (isVideoOn) { createLocalVideoStream() + } peerConnection.delegate = self } @@ -219,17 +222,25 @@ final class WebRTCClient: NSObject { } func renderRemoteVideo(to renderer: RTCVideoRenderer) { + debugPrint("renderRemoteVideo called, remoteVideoTrack: \(remoteVideoTrack != nil)") + remoteRenderer = renderer remoteVideoTrack?.add(renderer) } + func enableVideo(_ enabled: Bool) { + localVideoTrack?.isEnabled = enabled + } + + func enableAudio(_ enabled: Bool) { + localAudioTrack?.isEnabled = enabled + } + private func createLocalVideoStream() { localVideoTrack = createVideoTrack() if let localVideoTrack = localVideoTrack { peerConnection.add(localVideoTrack, streamIds: [streamId]) - remoteVideoTrack = peerConnection.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack } - } private func createLocalAudioStream() { @@ -268,6 +279,19 @@ extension WebRTCClient: RTCPeerConnectionDelegate { debugPrint("peerConnection didRemove stream:\(stream)") } + func peerConnection(_ peerConnection: RTCPeerConnection, didAdd rtpReceiver: RTCRtpReceiver, streams mediaStreams: [RTCMediaStream]) { + debugPrint("peerConnection didAdd rtpReceiver: \(rtpReceiver.track?.kind ?? "unknown")") + if rtpReceiver.track?.kind == kRTCMediaStreamTrackKindVideo { + remoteVideoTrack = rtpReceiver.track as? RTCVideoTrack + debugPrint("Remote video track assigned: \(remoteVideoTrack != nil)") + // Add to renderer if we have one stored + if let renderer = remoteRenderer { + remoteVideoTrack?.add(renderer) + debugPrint("Added remote video track to renderer") + } + } + } + func peerConnectionShouldNegotiate(_: RTCPeerConnection) { debugPrint("peerConnectionShouldNegotiate") } From 79616bccf1704239726fce96f08f9d2f7a550e09 Mon Sep 17 00:00:00 2001 From: sirknightj Date: Fri, 3 Oct 2025 11:03:02 -0700 Subject: [PATCH 3/6] Adjust to 3 total attempts per try --- Swift/KVSiOSApp/VideoViewController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Swift/KVSiOSApp/VideoViewController.swift b/Swift/KVSiOSApp/VideoViewController.swift index f735cc2..fea4963 100755 --- a/Swift/KVSiOSApp/VideoViewController.swift +++ b/Swift/KVSiOSApp/VideoViewController.swift @@ -125,9 +125,9 @@ class VideoViewController: UIViewController { let tenMinutesAgo = Date().addingTimeInterval(-600) storageSessionAttempts = storageSessionAttempts.filter { $0 > tenMinutesAgo } - // Check if we've exceeded 2 attempts in the last 10 minutes - if storageSessionAttempts.count >= 2 { - print("Too many storage session attempts (5) within 10 minutes. Stopping retries.") + // Check if we've exceeded 3 attempts in the last 10 minutes + if storageSessionAttempts.count >= 3 { + print("Too many storage session attempts (3) within 10 minutes. Stopping retries.") return } From 2ae21769c41e6f729025738364bd49b3e9004518 Mon Sep 17 00:00:00 2001 From: sirknightj Date: Fri, 3 Oct 2025 12:51:50 -0700 Subject: [PATCH 4/6] Fixing the UI display --- .../ChannelConfigurationViewController.swift | 2 +- Swift/KVSiOSApp/VideoViewController.swift | 44 +++++++++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/Swift/KVSiOSApp/ChannelConfigurationViewController.swift b/Swift/KVSiOSApp/ChannelConfigurationViewController.swift index da640b3..9ba0853 100755 --- a/Swift/KVSiOSApp/ChannelConfigurationViewController.swift +++ b/Swift/KVSiOSApp/ChannelConfigurationViewController.swift @@ -242,7 +242,7 @@ class ChannelConfigurationViewController: UIViewController, UITextFieldDelegate // Master mode: Both audio and video required popUpError(title: "Invalid Configuration", message: "Video and audio must be enabled for WebRTC ingestion master") return - } else { + } else if (!self.isMaster) { // Viewer mode: Video not allowed, audio optional if (self.isVideoEnabled.isOn) { popUpError(title: "Invalid Configuration", message: "Video is not allowed for WebRTC ingestion viewer") diff --git a/Swift/KVSiOSApp/VideoViewController.swift b/Swift/KVSiOSApp/VideoViewController.swift index fea4963..be63b21 100755 --- a/Swift/KVSiOSApp/VideoViewController.swift +++ b/Swift/KVSiOSApp/VideoViewController.swift @@ -57,35 +57,41 @@ class VideoViewController: UIViewController { let isIngestMode = signalingChannelArn != nil #if arch(arm64) - // Using metal (arm64 only) - let localRenderer = RTCMTLVideoView(frame: isIngestMode ? view.frame : (localVideoView?.frame ?? CGRect.zero)) + let localRenderer = RTCMTLVideoView(frame: localVideoView?.frame ?? CGRect.zero) let remoteRenderer = RTCMTLVideoView(frame: view.frame) localRenderer.videoContentMode = .scaleAspectFill remoteRenderer.videoContentMode = .scaleAspectFill #else - // Using OpenGLES for the rest - let localRenderer = RTCEAGLVideoView(frame: isIngestMode ? view.frame : (localVideoView?.frame ?? CGRect.zero)) + let localRenderer = RTCEAGLVideoView(frame: localVideoView?.frame ?? CGRect.zero) let remoteRenderer = RTCEAGLVideoView(frame: view.frame) #endif - if (!isIngestMode || !self.isMaster) && isVideoEnabled { + if isIngestMode && isMaster { + // Ingestion master: local view only webRTCClient.startCaptureLocalVideo(renderer: localRenderer) - } - - // Always set up remote video rendering - webRTCClient.renderRemoteVideo(to: remoteRenderer) - - // Only show local video view if we're actually capturing local video - if (!isIngestMode || !self.isMaster) && isVideoEnabled { - if let localVideoView = self.localVideoView { - embedView(localRenderer, into: localVideoView) + embedView(localRenderer, into: view) + view.sendSubview(toBack: localRenderer) + localVideoView?.isHidden = true + } else if isIngestMode && !isMaster { + // Ingestion viewer: remote view only + webRTCClient.renderRemoteVideo(to: remoteRenderer) + embedView(remoteRenderer, into: view) + view.sendSubview(toBack: remoteRenderer) + localVideoView?.isHidden = true + } else { + // Non-ingestion: remote fullscreen + local in corner + if isVideoEnabled { + webRTCClient.startCaptureLocalVideo(renderer: localRenderer) + if let localVideoView = self.localVideoView { + embedView(localRenderer, into: localVideoView) + } + } else { + localVideoView?.isHidden = true } - } else if let localVideoView = self.localVideoView { - localVideoView.isHidden = true + webRTCClient.renderRemoteVideo(to: remoteRenderer) + embedView(remoteRenderer, into: view) + view.sendSubview(toBack: remoteRenderer) } - - embedView(remoteRenderer, into: view) - view.sendSubview(toBack: remoteRenderer) } private func embedView(_ view: UIView, into containerView: UIView) { From 461f7d56c4a8e18ff66c0b69e502626ce5fce497 Mon Sep 17 00:00:00 2001 From: sirknightj Date: Fri, 3 Oct 2025 13:45:01 -0700 Subject: [PATCH 5/6] Update the unit tests --- .../VideoViewControllerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Swift/AWSKinesisVideoWebRTCDemoAppTests/VideoViewControllerTests.swift b/Swift/AWSKinesisVideoWebRTCDemoAppTests/VideoViewControllerTests.swift index dbb7933..c0ce572 100755 --- a/Swift/AWSKinesisVideoWebRTCDemoAppTests/VideoViewControllerTests.swift +++ b/Swift/AWSKinesisVideoWebRTCDemoAppTests/VideoViewControllerTests.swift @@ -20,9 +20,9 @@ class VideoViewControllerTests: XCTestCase{ signalingClient!.connect() RTCIceServersList.append(RTCIceServer.init(urlStrings: ["stun:stun.kinesisvideo." + "us-west-2" + ".amazonaws.com:443"])) - webRTCClient = WebRTCClient(iceServers: RTCIceServersList, isAudioOn: true) + webRTCClient = WebRTCClient(iceServers: RTCIceServersList, isAudioOn: true, isVideoOn: true) webRTCClient!.delegate = channelVC - videoViewController = VideoViewController(webRTCClient: self.webRTCClient!, signalingClient: self.signalingClient!, localSenderClientID: "randomClientID", isMaster: true, mediaServerEndPoint: nil) + videoViewController = VideoViewController(webRTCClient: self.webRTCClient!, signalingClient: self.signalingClient!, localSenderClientID: "randomClientID", isMaster: true, signalingChannelArn: nil) } override func tearDown() { From 28979862254dbbd0d20e5b60c30abc27639bb6d0 Mon Sep 17 00:00:00 2001 From: sirknightj Date: Tue, 7 Oct 2025 10:09:29 -0700 Subject: [PATCH 6/6] Adjust the class name for view element from 'zz' to 'View' --- Swift/KVSiOSApp/Main.storyboard | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Swift/KVSiOSApp/Main.storyboard b/Swift/KVSiOSApp/Main.storyboard index 8ac3090..b17a085 100755 --- a/Swift/KVSiOSApp/Main.storyboard +++ b/Swift/KVSiOSApp/Main.storyboard @@ -552,7 +552,7 @@ - + @@ -725,7 +725,7 @@ - + @@ -733,7 +733,7 @@ - + @@ -778,7 +778,7 @@ - +