|
|
|
//
|
|
|
|
// ViewController.swift
|
|
|
|
// Runner
|
|
|
|
//
|
|
|
|
// Created by Mohammad Aljammal & Elham Rababah on 23/6/20.
|
|
|
|
// Copyright © 2020 The Chromium Authors. All rights reserved.
|
|
|
|
//
|
|
|
|
|
|
|
|
import UIKit
|
|
|
|
import OpenTok
|
|
|
|
import Alamofire
|
|
|
|
import AADraggableView
|
|
|
|
|
|
|
|
class VideoCallViewController: UIViewController {
|
|
|
|
|
|
|
|
var session: OTSession?
|
|
|
|
var publisher: OTPublisher?
|
|
|
|
var subscriber: OTSubscriber?
|
|
|
|
|
|
|
|
var kApiKey:String = ""
|
|
|
|
|
|
|
|
var kSessionId:String = ""
|
|
|
|
|
|
|
|
var kToken:String = ""
|
|
|
|
|
|
|
|
var VC_ID: Int = 0
|
|
|
|
var TokenID: String = ""
|
|
|
|
var generalid : String = ""
|
|
|
|
var DoctorId: Int = 0
|
|
|
|
var baseUrl:String = ""
|
|
|
|
|
|
|
|
var callBack: ICallProtocol?
|
|
|
|
var timer = Timer()
|
|
|
|
var seconds = 55
|
|
|
|
var isUserConnect : Bool = false
|
|
|
|
|
|
|
|
var onRectFloat:((Bool)->Void)? = nil
|
|
|
|
var onCircleFloat:((Bool)->Void)? = nil
|
|
|
|
var onCallConnect:(()->Void)? = nil
|
|
|
|
var onCallDisconnect:(()->Void)? = nil
|
|
|
|
|
|
|
|
|
|
|
|
@IBOutlet weak var lblRemoteUsername: UILabel!
|
|
|
|
|
|
|
|
// Bottom Actions
|
|
|
|
@IBOutlet weak var videoMuteBtn: UIButton!
|
|
|
|
@IBOutlet weak var micMuteBtn: UIButton!
|
|
|
|
@IBOutlet weak var camSwitchBtn: UIButton!
|
|
|
|
|
|
|
|
@IBOutlet var minimizeConstraint: [NSLayoutConstraint]!
|
|
|
|
@IBOutlet var maximisedConstraint: [NSLayoutConstraint]!
|
|
|
|
|
|
|
|
@IBOutlet weak var btnMinimize: UIButton!
|
|
|
|
@IBOutlet weak var hideVideoBtn: UIButton!
|
|
|
|
var localVideoDraggable:AADraggableView?
|
|
|
|
@IBOutlet weak var controlButtons: UIView!
|
|
|
|
@IBOutlet weak var remoteVideoMutedIndicator: UIImageView!
|
|
|
|
@IBOutlet weak var localVideoMutedBg: UIView!
|
|
|
|
|
|
|
|
@IBOutlet weak var btnScreenTap: UIButton!
|
|
|
|
@IBOutlet weak var localVideoContainer: UIView!
|
|
|
|
@IBOutlet weak var topBar: UIView!
|
|
|
|
@IBOutlet weak var lblCallDuration: UILabel!
|
|
|
|
@IBOutlet weak var fullVideoView: UIView!
|
|
|
|
@IBOutlet weak var smallVideoView: UIView!{
|
|
|
|
didSet{
|
|
|
|
smallVideoView.layer.borderColor = UIColor.white.cgColor
|
|
|
|
localVideoDraggable = smallVideoView?.superview as? AADraggableView
|
|
|
|
localVideoDraggable?.reposition = .edgesOnly
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
override func viewDidLoad() {
|
|
|
|
super.viewDidLoad()
|
|
|
|
localVideoDraggable?.respectedView = localVideoContainer
|
|
|
|
}
|
|
|
|
|
|
|
|
@objc func click(gesture:UIGestureRecognizer){
|
|
|
|
gesture.view?.removeFromSuperview()
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func btnOnScreenTapped(_ sender: Any) {
|
|
|
|
if(hideVideoBtn.isSelected){
|
|
|
|
circleFloatBtnTapped(hideVideoBtn)
|
|
|
|
|
|
|
|
}else if(btnMinimize.isSelected){
|
|
|
|
btnMinimizeTapped(btnMinimize)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func btnSwipeVideoTapped(_ sender: Any) {
|
|
|
|
// let smallVdoRender = smallVideoView.subviews.first
|
|
|
|
// let fullVdoRender = fullVideoView.subviews.first
|
|
|
|
// if let vdo = smallVdoRender{
|
|
|
|
// fullVideoView.addSubview(vdo)
|
|
|
|
// }
|
|
|
|
// if let vdo = fullVdoRender{
|
|
|
|
// smallVideoView.addSubview(vdo)
|
|
|
|
// }
|
|
|
|
//
|
|
|
|
// layoutVideoRenderViews()
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func didClickMuteButton(_ sender: UIButton) {
|
|
|
|
sender.isSelected = !sender.isSelected
|
|
|
|
publisher!.publishAudio = !sender.isSelected
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func didClickSpeakerButton(_ sender: UIButton) {
|
|
|
|
sender.isSelected = !sender.isSelected
|
|
|
|
subscriber?.subscribeToAudio = !sender.isSelected
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func didClickVideoMuteButton(_ sender: UIButton) {
|
|
|
|
sender.isSelected = !sender.isSelected
|
|
|
|
if publisher!.publishVideo {
|
|
|
|
publisher!.publishVideo = false
|
|
|
|
} else {
|
|
|
|
publisher!.publishVideo = true
|
|
|
|
}
|
|
|
|
smallVideoView.isHidden = sender.isSelected
|
|
|
|
localVideoMutedBg.isHidden = !sender.isSelected
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@IBAction func didClickSwitchCameraButton(_ sender: UIButton) {
|
|
|
|
sender.isSelected = !sender.isSelected
|
|
|
|
if sender.isSelected {
|
|
|
|
publisher!.cameraPosition = AVCaptureDevice.Position.front
|
|
|
|
} else {
|
|
|
|
publisher!.cameraPosition = AVCaptureDevice.Position.back
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func hangUp(_ sender: UIButton) {
|
|
|
|
callBack?.sessionDone(res:["callResponse":"CallEnd"])
|
|
|
|
sessionDisconnect()
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func circleFloatBtnTapped(_ sender: UIButton) {
|
|
|
|
sender.isSelected = !sender.isSelected
|
|
|
|
onCircleFloat?(sender.isSelected)
|
|
|
|
topBar.isHidden = sender.isSelected
|
|
|
|
controlButtons.isHidden = sender.isSelected
|
|
|
|
smallVideoView.isHidden = sender.isSelected
|
|
|
|
self.publisher?.view?.layoutIfNeeded()
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func btnMinimizeTapped(_ sender: UIButton) {
|
|
|
|
minimizeVideoState(state: !sender.isSelected)
|
|
|
|
btnScreenTap.isHidden = !sender.isSelected
|
|
|
|
}
|
|
|
|
|
|
|
|
func minimizeVideoState(state:Bool){
|
|
|
|
btnMinimize.isSelected = state
|
|
|
|
onRectFloat?(state)
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate(state ? minimizeConstraint : maximisedConstraint)
|
|
|
|
NSLayoutConstraint.deactivate(state ? maximisedConstraint : minimizeConstraint)
|
|
|
|
localVideoDraggable?.enable(!state)
|
|
|
|
|
|
|
|
lblRemoteUsername.isHidden = state
|
|
|
|
hideVideoBtn.isHidden = !state
|
|
|
|
lblCallDuration.superview?.isHidden = !hideVideoBtn.isHidden
|
|
|
|
|
|
|
|
UIView.animate(withDuration: 0.5) {
|
|
|
|
self.videoMuteBtn.isHidden = state
|
|
|
|
self.micMuteBtn.isHidden = state
|
|
|
|
self.camSwitchBtn.isHidden = state
|
|
|
|
self.layoutVideoRenderViews()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func layoutVideoRenderViews(){
|
|
|
|
if let publisherVdoSize = publisher?.view?.superview?.bounds.size{
|
|
|
|
publisher?.view?.frame = CGRect(x: 0, y: 0, width: publisherVdoSize.width, height: publisherVdoSize.height)
|
|
|
|
}
|
|
|
|
if let subscriberVdoSize = subscriber?.view?.superview?.bounds.size{
|
|
|
|
subscriber?.view?.frame = CGRect(x: 0, y: 0, width: subscriberVdoSize.width, height: subscriberVdoSize.height)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var durationTimer:Timer?;
|
|
|
|
func startUpdateCallDuration(){
|
|
|
|
var seconds = 0
|
|
|
|
durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
|
|
|
|
seconds = seconds+1
|
|
|
|
let durationSegments = (seconds / 3600, (seconds % 3600) / 60, (seconds % 3600) % 60)
|
|
|
|
let hours = String(format: "%02d", durationSegments.0)
|
|
|
|
let mins = String(format: "%02d", durationSegments.1)
|
|
|
|
let secs = String(format: "%02d", durationSegments.2)
|
|
|
|
let durationString = "\(mins):\(secs)"
|
|
|
|
|
|
|
|
self.lblCallDuration.text = durationString
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func start(params:VideoCallRequestParameters){
|
|
|
|
lblRemoteUsername.text = params.patientName ?? "- - -"
|
|
|
|
btnScreenTap.isHidden = true
|
|
|
|
hideVideoBtn.isHidden = true
|
|
|
|
|
|
|
|
self.kApiKey = params.apiKey ?? ""
|
|
|
|
self.kSessionId = params.sessionId ?? ""
|
|
|
|
self.kToken = params.token ?? ""
|
|
|
|
self.VC_ID = params.vcId ?? 0
|
|
|
|
self.generalid = params.generalId ?? ""
|
|
|
|
self.TokenID = params.tokenId ?? ""
|
|
|
|
self.DoctorId = params.doctorId ?? 0
|
|
|
|
self.baseUrl = params.baseUrl ?? ""
|
|
|
|
|
|
|
|
askForMicrophonePermission()
|
|
|
|
requestCameraPermissionsIfNeeded()
|
|
|
|
hideVideoMuted()
|
|
|
|
setupSession()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
private func changeCallStatus(callStatus:Int){
|
|
|
|
let URL_USER_REGISTER = baseUrl+"LiveCareApi/DoctorApp/ChangeCallStatus"
|
|
|
|
let headers: HTTPHeaders = ["Content-Type":"application/json","Accept":"application/json",]
|
|
|
|
|
|
|
|
let parameters = [
|
|
|
|
"CallStatus":callStatus,
|
|
|
|
"VC_ID": VC_ID,
|
|
|
|
"TokenID": TokenID,
|
|
|
|
"generalid": generalid,
|
|
|
|
"DoctorId" : DoctorId ,
|
|
|
|
] as [String : Any]
|
|
|
|
|
|
|
|
AF.request(URL_USER_REGISTER, method: .post,parameters: parameters, encoding: JSONEncoding.default, headers: headers).responseJSON{
|
|
|
|
response in
|
|
|
|
if let result = response.value {
|
|
|
|
let jsonData = result as! NSObject
|
|
|
|
let resultVal = jsonData.value(forKey: "Result")
|
|
|
|
print(resultVal as Any)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func getSessionStatus() {
|
|
|
|
let URL_USER_REGISTER = baseUrl+"LiveCareApi/DoctorApp/GetSessionStatus"
|
|
|
|
let headers: HTTPHeaders = [
|
|
|
|
"Content-Type":"application/json",
|
|
|
|
"Accept":"application/json",
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
let parameters = [
|
|
|
|
"VC_ID": VC_ID,
|
|
|
|
"TokenID": TokenID,
|
|
|
|
"generalid": generalid,
|
|
|
|
"DoctorId" : DoctorId ,
|
|
|
|
] as [String : Any]
|
|
|
|
AF.request(URL_USER_REGISTER, method: .post,parameters: parameters, encoding: JSONEncoding.default, headers: headers).responseJSON{
|
|
|
|
response in
|
|
|
|
if self.isUserConnect {
|
|
|
|
} else {
|
|
|
|
if let result = response.value {
|
|
|
|
let jsonData = result as! NSObject
|
|
|
|
if((jsonData.value(forKey: "SessionStatus")) as! Int == 2 || (jsonData.value(forKey: "SessionStatus")) as! Int == 3) {
|
|
|
|
//jsonData
|
|
|
|
let jsonObject: [String: Any] = [
|
|
|
|
"sessionStatus": result ,
|
|
|
|
"callResponse": "CallNotRespond",
|
|
|
|
]
|
|
|
|
self.callBack?.sessionNotResponded(res: jsonObject)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
self.sessionDisconnect();
|
|
|
|
self.timer.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: -Microphone Camera and Permission Request
|
|
|
|
func askForMicrophonePermission() {
|
|
|
|
switch AVAudioSession.sharedInstance().recordPermission {
|
|
|
|
case AVAudioSession.RecordPermission.granted:
|
|
|
|
break
|
|
|
|
case AVAudioSession.RecordPermission.denied:
|
|
|
|
break
|
|
|
|
case AVAudioSession.RecordPermission.undetermined:
|
|
|
|
// This is the initial state before a user has made any choice
|
|
|
|
// You can use this spot to request permission here if you want
|
|
|
|
AVAudioSession.sharedInstance().requestRecordPermission({ granted in
|
|
|
|
// Check for granted
|
|
|
|
})
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func notifyUserOfCameraAccessDenial() {
|
|
|
|
// display a useful message asking the user to grant permissions from within Settings > Privacy > Camera
|
|
|
|
}
|
|
|
|
|
|
|
|
func sessionDisconnect() {
|
|
|
|
changeCallStatus(callStatus: 16)
|
|
|
|
if (session != nil) {
|
|
|
|
print("disconnecting....")
|
|
|
|
session!.disconnect(nil)
|
|
|
|
dismiss(animated: true)
|
|
|
|
}
|
|
|
|
dismiss(animated: true)
|
|
|
|
onCallDisconnect?()
|
|
|
|
durationTimer?.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
func requestCameraPermissionsIfNeeded() {
|
|
|
|
|
|
|
|
// check camera authorization status
|
|
|
|
let authStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
|
|
switch authStatus {
|
|
|
|
case .authorized: break
|
|
|
|
// camera authorized
|
|
|
|
// do camera intensive stuff
|
|
|
|
case .notDetermined:
|
|
|
|
// request authorization
|
|
|
|
|
|
|
|
AVCaptureDevice.requestAccess(for: .video, completionHandler: { granted in
|
|
|
|
DispatchQueue.main.async(execute: {
|
|
|
|
|
|
|
|
if granted {
|
|
|
|
// do camera intensive stuff
|
|
|
|
} else {
|
|
|
|
self.notifyUserOfCameraAccessDenial()
|
|
|
|
}
|
|
|
|
})
|
|
|
|
})
|
|
|
|
case .restricted, .denied:
|
|
|
|
DispatchQueue.main.async(execute: {
|
|
|
|
self.notifyUserOfCameraAccessDenial()
|
|
|
|
})
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func hideVideoMuted() {
|
|
|
|
remoteVideoMutedIndicator.isHidden = true
|
|
|
|
localVideoMutedBg.isHidden = true
|
|
|
|
}
|
|
|
|
|
|
|
|
func setupSession() {
|
|
|
|
//setup one time session
|
|
|
|
if (session != nil) {
|
|
|
|
session = nil
|
|
|
|
}
|
|
|
|
session = OTSession(
|
|
|
|
apiKey: kApiKey,
|
|
|
|
sessionId: kSessionId,
|
|
|
|
delegate: self)
|
|
|
|
|
|
|
|
var error: OTError?
|
|
|
|
session!.connect(withToken: kToken,error: &error)
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
func connectToAnOpenTokSession() {
|
|
|
|
session = OTSession(apiKey: kApiKey, sessionId: kSessionId, delegate: self)
|
|
|
|
var error: OTError?
|
|
|
|
session?.connect(withToken: kToken, error: &error)
|
|
|
|
if error != nil {
|
|
|
|
print(error!)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func showAlert(_ string: String?) {
|
|
|
|
// show alertview on main UI
|
|
|
|
DispatchQueue.main.async(execute: {
|
|
|
|
let alertVC = UIAlertController(
|
|
|
|
title: "OTError",
|
|
|
|
message: string,
|
|
|
|
preferredStyle: .alert)
|
|
|
|
self.present(alertVC, animated: true)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@objc func updateTimer(){
|
|
|
|
seconds -= 1 //This will decrement(count down)the seconds.
|
|
|
|
print(seconds)
|
|
|
|
if seconds == 0 {
|
|
|
|
getSessionStatus()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
extension VideoCallViewController: OTSessionDelegate {
|
|
|
|
|
|
|
|
func sessionDidConnect(_ session: OTSession) {
|
|
|
|
print("The client connected to the OpenTok session.")
|
|
|
|
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: (#selector(VideoCallViewController.updateTimer)), userInfo: nil, repeats: true)
|
|
|
|
setupPublisher()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
func setupPublisher() {
|
|
|
|
let settings = OTPublisherSettings()
|
|
|
|
settings.name = UIDevice.current.name
|
|
|
|
publisher = OTPublisher(delegate: self, settings: settings)
|
|
|
|
|
|
|
|
var error: OTError? = nil
|
|
|
|
session!.publish(publisher!, error: &error)
|
|
|
|
if error != nil {
|
|
|
|
showAlert(error?.localizedDescription)
|
|
|
|
}
|
|
|
|
|
|
|
|
publisher?.view?.tag = 11
|
|
|
|
publisher?.view?.layer.cornerRadius = 5
|
|
|
|
publisher?.view?.clipsToBounds = true
|
|
|
|
smallVideoView.addSubview((publisher?.view)!)
|
|
|
|
layoutVideoRenderViews()
|
|
|
|
}
|
|
|
|
|
|
|
|
func sessionDidDisconnect(_ session: OTSession) {
|
|
|
|
print("The client disconnected from the OpenTok session.")
|
|
|
|
}
|
|
|
|
|
|
|
|
func session(_ session: OTSession, didFailWithError error: OTError) {
|
|
|
|
changeCallStatus(callStatus: 16)
|
|
|
|
print("The client failed to connect to the OpenTok session: \(error).")
|
|
|
|
}
|
|
|
|
|
|
|
|
func session(
|
|
|
|
_ session: OTSession,
|
|
|
|
connectionDestroyed connection: OTConnection
|
|
|
|
) {
|
|
|
|
|
|
|
|
if subscriber?.stream!.connection.connectionId == connection.connectionId {
|
|
|
|
cleanupSubscriber()
|
|
|
|
}
|
|
|
|
sessionDisconnect()
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func session(_ session: OTSession, streamCreated stream: OTStream) {
|
|
|
|
subscriber = OTSubscriber(stream: stream, delegate: self)
|
|
|
|
guard let subscriber = subscriber else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var error: OTError?
|
|
|
|
session.subscribe(subscriber, error: &error)
|
|
|
|
guard error == nil else {
|
|
|
|
print(error!)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let subscriberView = subscriber.view else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
subscriberView.tag = 22
|
|
|
|
fullVideoView.addSubview(subscriberView)
|
|
|
|
layoutVideoRenderViews()
|
|
|
|
|
|
|
|
startUpdateCallDuration()
|
|
|
|
onCallConnect?()
|
|
|
|
}
|
|
|
|
|
|
|
|
func setupSubscribe(_ stream: OTStream?) {
|
|
|
|
subscriber = OTSubscriber(stream: stream!, delegate: self)
|
|
|
|
|
|
|
|
var error: OTError? = nil
|
|
|
|
session!.subscribe(subscriber!, error: &error)
|
|
|
|
if error != nil {
|
|
|
|
showAlert(error!.localizedDescription)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
func session(_ session: OTSession, streamDestroyed stream: OTStream) {
|
|
|
|
|
|
|
|
|
|
|
|
if subscriber?.stream?.streamId == stream.streamId {
|
|
|
|
cleanupSubscriber()
|
|
|
|
}
|
|
|
|
print("A stream was destroyed in the session.")
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func cleanupSubscriber() {
|
|
|
|
subscriber?.view!.removeFromSuperview()
|
|
|
|
subscriber = nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func session(
|
|
|
|
_ session: OTSession?,
|
|
|
|
connectionCreated connection: OTConnection?
|
|
|
|
) {
|
|
|
|
// startTimer(callDuration, warningDuration)
|
|
|
|
if let connectionId = connection?.connectionId {
|
|
|
|
print("session connectionCreated (\(connectionId))")
|
|
|
|
}
|
|
|
|
changeCallStatus(callStatus: 3)
|
|
|
|
isUserConnect = true
|
|
|
|
timer.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
extension VideoCallViewController: OTPublisherDelegate {
|
|
|
|
func publisher(_ publisher: OTPublisherKit, didFailWithError error: OTError) {
|
|
|
|
print("The publisher failed: \(error)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
extension VideoCallViewController: OTSubscriberDelegate {
|
|
|
|
public func subscriberDidConnect(toStream subscriber: OTSubscriberKit) {
|
|
|
|
print("The subscriber did connect to the stream.")
|
|
|
|
}
|
|
|
|
|
|
|
|
public func subscriber(_ subscriber: OTSubscriberKit, didFailWithError error: OTError) {
|
|
|
|
print("The subscriber failed to connect to the stream.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|