iOSで複数台接続やります。
- iOS 1:1はこちら → webRTC for ios vol3 tricke ICE
- Web複数台はこちら → webRTC for web vol2 複数人での接続
成果物
流れ
流れはweb版と同じ。
roomKeyはとりあえず固定で。
参考
実装
こちらもweb版とほぼ同じ。
Wamp
簡易wampサーバーはこちら → webRTC for ios vol2
connectしてsubscribeする。
あとはsessionの提供。
Wamp.swift
import UIKit
import Swamp
enum WampTopic:String{
case callme = "com.nakadoribook.webrtc.[roomId].callme"
, close = "com.nakadoribook.webrtc.[roomId].close"
, answer = "com.nakadoribook.webrtc.[roomId].[id].answer"
, offer = "com.nakadoribook.webrtc.[roomId].[id].offer"
, candidate = "com.nakadoribook.webrtc.[roomId].[id].candidate"
}
typealias WampOnOpenHandler = (()->())
typealias WampReceiveAnswerHandler = ((_ targetId:String, _ sdp:NSDictionary)->())
typealias WampReceiveOfferHandler = ((_ targetId:String, _ sdp:NSDictionary)->())
typealias WampReceiveCandidateHandler = ((_ targetId:String, _ candidate:NSDictionary)->())
typealias WampReceiveCallmeHandler = ((_ targetId:String)->())
typealias WampOncloseConnectionHandler = ((_ targetId:String)->())
typealias WampCallbacks = (onOpen:WampOnOpenHandler
, onReceiveAnswer:WampReceiveAnswerHandler
, onReceiveOffer:WampReceiveOfferHandler
, onReceiveCallme:WampReceiveCallmeHandler
, onCloseConnection:WampOncloseConnectionHandler)
class Wamp: NSObject, SwampSessionDelegate {
static let sharedInstance = Wamp()
private var _session:SwampSession!
private override init() {
super.init()
}
var session:SwampSession{
get{
return _session
}
}
func connect(){
let swampTransport = WebSocketSwampTransport(wsEndpoint: URL(string: "wss://nakadoribooks-webrtc.herokuapp.com")!)
let swampSession = SwampSession(realm: "realm1", transport: swampTransport)
swampSession.delegate = self
swampSession.connect()
}
private func resultToSdp(results:[Any]?)->NSDictionary?{
if let sdp = results?.first as? NSDictionary{
return sdp;
}
return nil;
}
// MARK: SwampSessionDelegate
func swampSessionHandleChallenge(_ authMethod: String, extra: [String: Any]) -> String{
return SwampCraAuthHelper.sign("secret123", challenge: extra["challenge"] as! String)
}
func swampSessionConnected(_ session: SwampSession, sessionId: Int){
self._session = session
// subscribe
session.subscribe(endpointAnswer(targetId: userId), onSuccess: { (subscription) in
}, onError: { (details, error) in
}) { (details, args, kwArgs) in
guard let args = args, let targetId = args[0] as? String, let sdpString = args[1] as? String else{
print("no args answer")
return
}
let sdp = try! JSONSerialization.jsonObject(with: sdpString.data(using: .utf8)!, options: .allowFragments) as! NSDictionary
self.callbacks.onReceiveAnswer(targetId, sdp)
}
session.subscribe(endpointOffer(targetId: userId), onSuccess: { (subscription) in
}, onError: { (details, error) in
}) { (details, args, kwArgs) in
guard let args = args, let targetId = args[0] as? String, let sdpString = args[1] as? String else{
print("no args offer")
return
}
let sdp = try! JSONSerialization.jsonObject(with: sdpString.data(using: .utf8)!, options: .allowFragments) as! NSDictionary
self.callbacks.onReceiveOffer(targetId, sdp)
}
session.subscribe(endpointCallme(), onSuccess: { (subscription) in
}, onError: { (details, error) in
}) { (details, args, kwArgs) in
guard let args = args, let targetId = args[0] as? String else{
print("no args callMe")
return
}
if targetId == self.userId{
return;
}
self.callbacks.onReceiveCallme(targetId)
}
session.subscribe(endpointClose(), onSuccess: { (subscription) in
}, onError: { (details, error) in
}) { (details, args, kwArgs) in
guard let args = args, let targetId = args[0] as? String else{
print("no args close")
return
}
if targetId == self.userId{
return;
}
self.callbacks.onCloseConnection(targetId)
}
self.callbacks.onOpen()
}
func swampSessionEnded(_ reason: String){
print("swampSessionEnded: \(reason)")
}
private var roomKey:String!
private var userId:String!
private var callbacks:WampCallbacks!
func setup(roomKey:String, userId:String, callbacks:WampCallbacks){
self.roomKey = roomKey
self.userId = userId
self.callbacks = callbacks
}
static let HandshakeEndpint = "wss://nakadoribooks-webrtc.herokuapp.com"
private func roomTopic(base:String)->String{
return base.replacingOccurrences(of: "[roomId]", with: self.roomKey)
}
func endpointAnswer(targetId:String)->String{
return self.roomTopic(base: WampTopic.answer.rawValue.replacingOccurrences(of: "[id]", with: targetId))
}
func endpointOffer(targetId:String)->String{
return self.roomTopic(base: WampTopic.offer.rawValue.replacingOccurrences(of: "[id]", with: targetId))
}
func endpointCandidate(targetId:String)->String{
return self.roomTopic(base: WampTopic.candidate.rawValue.replacingOccurrences(of: "[id]", with: targetId))
}
func endpointCallme()->String{
return self.roomTopic(base: WampTopic.callme.rawValue)
}
func endpointClose()->String{
return self.roomTopic(base: WampTopic.close.rawValue)
}
}
RTCPeerConnectionまわり
static でlocalStreamを管理
接続の数だけインスタンスを作って相手のStreamを管理
WebRTC.swift
import UIKit
typealias WebRTCOnIceCandidateHandler = (_ candidate:NSDictionary) -> ()
typealias WebRTCOnAddedStream = (_ stream:RTCMediaStream, _ view:UIView) -> ()
typealias WebRTCOnRemoveStream = (_ stream:RTCMediaStream) -> ()
typealias WebRTCCallbacks = (onIceCandidate:WebRTCOnIceCandidateHandler, onAddedStream:WebRTCOnAddedStream, onRemoveStream:WebRTCOnRemoveStream)
class WebRTC: NSObject, RTCPeerConnectionDelegate, RTCEAGLVideoViewDelegate {
// ▼ inner class -----
class LocalViewDelegate:NSObject, RTCEAGLVideoViewDelegate{
private let localRenderView:RTCEAGLVideoView
init(localRenderView:RTCEAGLVideoView){
self.localRenderView = localRenderView
super.init()
localRenderView.delegate = self
}
// MARK: RTCEAGLVideoViewDelegate
func videoView(_ videoView: RTCEAGLVideoView, didChangeVideoSize size: CGSize) {
print("---- didChangeVideoSize -----")
let ratio:CGFloat = size.width / size.height
if ratio > 1.0{
// 横長
let height:CGFloat = WebRTC.ViewSize
let width:CGFloat = height * ratio
let x:CGFloat = (WebRTC.ViewSize - width) / 2.0
localRenderView.frame = CGRect(x: x, y: 0, width: width, height: height)
}else{
// 縦長
let width:CGFloat = WebRTC.ViewSize
let height:CGFloat = width / max(ratio, 0.1)
let y:CGFloat = (WebRTC.ViewSize - height) / 2.0
localRenderView.frame = CGRect(x: 0, y: y, width: width, height: height)
}
}
}
// ▼ static -----
private static let IceServerUrls = ["stun:stun.l.google.com:19302"]
private static let factory = RTCPeerConnectionFactory()
private static var localStream:RTCMediaStream?
private static var localRenderView = RTCEAGLVideoView()
private static let _localView = UIView(frame:CGRect(x:10, y:10, width:WebRTC.ViewSize, height:WebRTC.ViewSize))
private static var localViewDelegate:LocalViewDelegate!
private static let Margin:CGFloat = 20.0
static let ViewSize:CGFloat = (windowWidth() - (WebRTC.Margin * 3)) / 2.0
static func setup(){
let streamId = WebRTCUtil.idWithPrefix(prefix: "stream")
localStream = factory.mediaStream(withStreamId: streamId)
localViewDelegate = LocalViewDelegate(localRenderView: localRenderView)
_localView.backgroundColor = UIColor.white
_localView.clipsToBounds = true
_localView.addSubview(localRenderView)
setupLocalVideoTrack()
setupLocalAudioTrack()
}
static func disableVideo(){
localStream?.videoTracks.first?.isEnabled = false
}
static func enableVideo(){
localStream?.videoTracks.first?.isEnabled = true
}
static var localView:UIView{
get{
return _localView
}
}
private static func setupLocalVideoTrack(){
let localVideoSource = factory.avFoundationVideoSource(with: WebRTCUtil.mediaStreamConstraints())
let localVideoTrack = factory.videoTrack(with: localVideoSource, trackId: WebRTCUtil.idWithPrefix(prefix: "video"))
if let avSource = localVideoTrack.source as? RTCAVFoundationVideoSource{
avSource.useBackCamera = true
}
localVideoTrack.add(localRenderView)
localStream?.addVideoTrack(localVideoTrack)
}
private static func setupLocalAudioTrack(){
let localAudioTrack = factory.audioTrack(withTrackId: WebRTCUtil.idWithPrefix(prefix: "audio"))
localStream?.addAudioTrack(localAudioTrack)
}
// ▼ instance ------
private var remoteStream:RTCMediaStream?
private var remoteRenderView = RTCEAGLVideoView()
private let _remoteView = UIView(frame: CGRect(x: 0, y: 0, width: WebRTC.ViewSize, height: WebRTC.ViewSize))
private var peerConnection:RTCPeerConnection?
private let callbacks:WebRTCCallbacks
init(callbacks:WebRTCCallbacks){
self.callbacks = callbacks
super.init()
setupPeerConnection()
remoteView.clipsToBounds = true
remoteRenderView.delegate = self
remoteView.addSubview(remoteRenderView)
}
private func setupPeerConnection(){
let configuration = RTCConfiguration()
configuration.iceServers = [RTCIceServer(urlStrings: WebRTC.IceServerUrls)]
peerConnection = WebRTC.factory.peerConnection(with: configuration, constraints: WebRTCUtil.peerConnectionConstraints(), delegate: self)
peerConnection?.add(WebRTC.localStream!)
}
var remoteView:UIView{
get{
return _remoteView
}
}
func receiveCandidate(candidate:NSDictionary){
guard let candidate = candidate as? [AnyHashable:Any]
, let rtcCandidate = RTCIceCandidate(fromJSONDictionary: candidate) else{
print("invalid candiate")
return
}
self.peerConnection?.add(rtcCandidate)
}
func receiveAnswer(remoteSdp:NSDictionary){
guard let sdpContents = remoteSdp.object(forKey: "sdp") as? String else{
print("noSDp")
return;
}
let sdp = RTCSessionDescription(type: .answer, sdp: sdpContents)
// 1. remote SDP を登録
peerConnection?.setRemoteDescription(sdp, completionHandler: { (error) in
})
}
func receiveOffer(remoteSdp:NSDictionary, callback:@escaping (_ answerSdp:NSDictionary)->()){
guard let sdpContents = remoteSdp.object(forKey: "sdp") as? String else{
print("noSDp")
return;
}
// 1. remote SDP を登録
let remoteSdp = RTCSessionDescription(type: .offer, sdp: sdpContents)
peerConnection?.setRemoteDescription(remoteSdp, completionHandler: { (error) in
// 2. answerを作る
self.peerConnection?.answer(for: WebRTCUtil.answerConstraints(), completionHandler: { (sdp, error) in
guard let sdp = sdp else{
print("can not create sdp")
return;
}
// 3.ローカルにSDPを登録
self.peerConnection?.setLocalDescription(sdp, completionHandler: { (error) in
// 3. answer を送る
guard let localDescription = WebRTCUtil.jsonFromDescription(description: self.peerConnection?.localDescription) else{
print("no localDescription")
return ;
}
callback(localDescription)
})
})
})
}
func createOffer(callback:@escaping (_ offerSdp:NSDictionary)->()){
// 1. offerを作る
peerConnection?.offer(for: WebRTCUtil.mediaStreamConstraints(), completionHandler: { (description, error) in
guard let description = description else{
print("----- no description ----")
return;
}
// 2.ローカルにSDPを登録
self.peerConnection?.setLocalDescription(description, completionHandler: { (error) in
// 3. offer を送る
guard let localDescription = WebRTCUtil.jsonFromDescription(description: self.peerConnection?.localDescription) else{
print("no localDescription")
return ;
}
callback(localDescription)
})
})
}
func close(){
if let localStream = WebRTC.localStream{
self.peerConnection?.remove(localStream)
}
self.peerConnection?.close()
self.peerConnection = nil
}
// MARK: RTCPeerConnectionDelegate
// いったんスルー
public func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection){}
public func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream){}
public func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState){}
public func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]){}
public func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel){}
public func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState){}
public func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState){}
// for Trickle ice
public func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate){
if let candidateJson = WebRTCUtil.jsonFromCandidate(candidate: candidate){
self.callbacks.onIceCandidate(candidateJson)
}
}
public func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream){
self.remoteStream = stream
if let remoteVideoTrack = stream.videoTracks.first {
remoteVideoTrack.add(remoteRenderView)
}
DispatchQueue.main.async {
self.callbacks.onAddedStream(stream, self.remoteView)
}
}
// MARK: RTCEAGLVideoViewDelegate
func videoView(_ videoView: RTCEAGLVideoView, didChangeVideoSize size: CGSize) {
let ratio:CGFloat = size.width / size.height
if ratio > 1.0{
// 横長
let height:CGFloat = WebRTC.ViewSize
let width:CGFloat = height * ratio
let x:CGFloat = (WebRTC.ViewSize - width) / 2.0
remoteRenderView.frame = CGRect(x: x, y: 0, width: width, height: height)
}else{
// 縦長
let width:CGFloat = WebRTC.ViewSize
let height:CGFloat = width / max(ratio, 0.1)
let y:CGFloat = (WebRTC.ViewSize - height) / 2.0
remoteRenderView.frame = CGRect(x: 0, y: y, width: width, height: height)
}
}
}
一個分のコネクション管理
これも、一人の相手ごとに一個インスタンス作る
Connection.swift
import UIKit
typealias ConnectionOnAddedStream = (_ streamWrapper:StreamWrapper)->()
class StreamWrapper:NSObject{
let stream:RTCMediaStream
let targetId:String
let view:UIView
init(stream:RTCMediaStream, targetId:String, view:UIView){
self.stream = stream
self.targetId = targetId
self.view = view
super.init()
let overlay = UIView()
overlay.frame.size = view.frame.size
view.addSubview(overlay)
let labelBg = UIView()
labelBg.frame = CGRect(x: 0, y: view.frame.size.height - 30, width: view.frame.size.width, height: 30)
labelBg.backgroundColor = UIColor.white
labelBg.alpha = 0.8
overlay.addSubview(labelBg)
let label = UILabel()
label.frame = CGRect(x: 0, y: view.frame.size.height - 30, width: view.frame.size.width, height: 30)
label.textAlignment = .center
label.text = targetId
label.textColor = UIColor.black
overlay.addSubview(label)
}
}
class Connection: NSObject {
private let onAddedStream:ConnectionOnAddedStream
private var webRtc:WebRTC!
private let myId:String
let targetId:String
init(myId:String, targetId:String, onAddedStream:@escaping ConnectionOnAddedStream){
self.myId = myId
self.targetId = targetId
self.onAddedStream = onAddedStream
super.init()
webRtc = WebRTC(callbacks: (
onIceCandidate: {(iceCandidate:NSDictionary) -> Void in
let jsonData = try! JSONSerialization.data(withJSONObject: iceCandidate, options: [])
let jsonStr = String(bytes: jsonData, encoding: .utf8)!
let wamp = Wamp.sharedInstance
let topic = wamp.endpointCandidate(targetId: targetId)
wamp.session.publish(topic, options: [:], args: [jsonStr], kwargs: [:])
}
, onAddedStream: {(stream:RTCMediaStream, view:UIView) -> Void in
let streamWrapper = StreamWrapper(stream: stream, targetId: targetId, view:view)
self.onAddedStream(streamWrapper)
}
, onRemoveStream: {(stream:RTCMediaStream) -> Void in
}
))
// for tricke ice
subscribeCandidate()
}
private func subscribeCandidate(){
let wamp = Wamp.sharedInstance
let candidateTopic = wamp.endpointCandidate(targetId: myId)
Wamp.sharedInstance.session.subscribe(candidateTopic, onSuccess: { (subscription) in
}, onError: { (results, error) in
}) { (results, args, kwArgs) in
guard let candidateStr = args?.first as? String else{
print(args?.first)
print("no candidate")
return
}
let data = candidateStr.data(using: String.Encoding.utf8)!
let candidate = try! JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments) as! NSDictionary
self.webRtc.receiveCandidate(candidate: candidate)
}
}
deinit {
print("connection deinit")
}
func publishAnswer(offerSdp:NSDictionary){
webRtc.receiveOffer(remoteSdp: offerSdp) { (answerSdp) in
let wamp = Wamp.sharedInstance
let topic = wamp.endpointAnswer(targetId: self.targetId)
let jsonData = try! JSONSerialization.data(withJSONObject: answerSdp, options: [])
let jsonStr = String(bytes: jsonData, encoding: .utf8)!
Wamp.sharedInstance.session.publish(topic, options: [:], args: [self.myId, jsonStr], kwargs: [:])
}
}
func publishOffer(){
webRtc.createOffer { (offerSdp) in
let wamp = Wamp.sharedInstance
let topic = wamp.endpointOffer(targetId: self.targetId)
let jsonData = try! JSONSerialization.data(withJSONObject: offerSdp, options: [])
let jsonStr = String(bytes: jsonData, encoding: .utf8)!
wamp.session.publish(topic, options: [:], args: [self.myId, jsonStr], kwargs: [:])
}
}
func receiveAnswer(sdp:NSDictionary){
webRtc.receiveAnswer(remoteSdp: sdp)
}
func receiveCnadidate(candidate:NSDictionary){
}
func close(){
print("close connection")
webRtc.close()
}
}
アプリケーション
roomKey はとりあえず固定
ViewController.swift
import UIKit
import FirebaseDatabase
import UIKit
extension String {
static func getRandomStringWithLength(length: Int) -> String {
let alphabet = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
let upperBound = UInt32(alphabet.characters.count)
return String((0..<length).map { _ -> Character in
return alphabet[alphabet.index(alphabet.startIndex, offsetBy: Int(arc4random_uniform(upperBound)))]
})
}
}
class ViewController: UIViewController {
private let wamp = Wamp.sharedInstance
private var connectionList:[Connection] = []
private var streamWrapperList:[StreamWrapper] = []
private let remoteLayer = UIView(frame: windowFrame())
private let localLayer = UIView(frame: CGRect(x: 0, y: windowHeight()-WebRTC.ViewSize - 20, width: windowWidth(), height: WebRTC.ViewSize + 20))
private var roomKey:String!
private let userId = String.getRandomStringWithLength(length: 8)
private let toggleButton = UIButton()
deinit {
print("ViewController deinit")
}
override func viewDidLoad() {
super.viewDidLoad()
setupRoom()
setupWamp()
setupStream()
Wamp.sharedInstance.connect()
view.addSubview(remoteLayer)
view.addSubview(localLayer)
localLayer.backgroundColor = UIColor.gray
localLayer.addSubview(WebRTC.localView)
let labelX = WebRTC.ViewSize + 20.0
let labelWidth = windowWidth() - labelX - 10
let label = UILabel(frame: CGRect(x: labelX, y: 10, width: labelWidth, height: 50))
label.numberOfLines = 2
label.font = UIFont.systemFont(ofSize: 20.0)
label.textColor = UIColor.white
label.text = "You\n(\(self.userId))"
label.sizeToFit()
localLayer.addSubview(label)
let y:CGFloat = label.frame.size.height + 20
toggleButton.frame = CGRect(x: labelX, y: y, width: labelWidth, height: 44)
toggleButton.backgroundColor = UIColor.white
toggleButton.layer.cornerRadius = 5
toggleButton.clipsToBounds = true
toggleButton.setTitleColor(UIColor.gray, for: .normal)
toggleButton.setTitle("Stop", for: .normal)
toggleButton.setTitle("Play", for: .selected)
toggleButton.addTarget(self, action: #selector(ViewController.tapToggle), for: .touchUpInside)
localLayer.addSubview(toggleButton)
}
private dynamic func tapToggle(){
toggleButton.isSelected = !toggleButton.isSelected
let active = !toggleButton.isSelected
if active{
WebRTC.enableVideo()
}else{
WebRTC.disableVideo()
}
}
private func setupStream(){
WebRTC.setup()
}
private func setupRoom(){
let roomKey:String? = "-Kr-JqhdoZ1YtdeO0-9r"
if let roomKey = roomKey{
self.roomKey = roomKey
return
}
let ref = Database.database().reference().child("rooms")
let roomRef = ref.childByAutoId()
self.roomKey = roomRef.key
print("roomKey:\(roomRef.key)")
}
private func setupWamp(){
let wamp = Wamp.sharedInstance
Wamp.sharedInstance.setup(roomKey: roomKey, userId: userId
, callbacks: (
onOpen:{() -> Void in
print("onOpen")
let topic = wamp.endpointCallme()
wamp.session.publish(topic, options: [:], args: [self.userId], kwargs: [:])
}
, onReceiveOffer:{(targetId:String, sdp:NSDictionary) -> Void in
print("onReceiveOffer")
let connection = self.createConnection(targetId: targetId)
connection.publishAnswer(offerSdp: sdp)
}
, onReceiveAnswer:{(targetId:String, sdp:NSDictionary) -> Void in
print("onReceiveAnswer")
guard let connection = self.findConnection(targetId: targetId) else{
return
}
connection.receiveAnswer(sdp: sdp)
}
, onReceiveCallme:{(targetId:String) -> Void in
print("onReceivCallme")
let connection = self.createConnection(targetId:targetId)
connection.publishOffer()
}
, onCloseConnection:{(targetId:String) -> Void in
print("onCloseConnection")
// removeConnection
guard let removeIndex = self.connectionList.index(where: { (row) -> Bool in
return row.targetId == targetId
}) else{
return
}
let connection = self.connectionList.remove(at: removeIndex)
connection.close()
// removeView
guard let streamIndex = self.streamWrapperList.index(where: { (row) -> Bool in
return row.targetId == targetId
}) else{
return;
}
let streamWrapper = self.streamWrapperList.remove(at: streamIndex)
streamWrapper.view.removeFromSuperview()
self.calcRemoteViewPosition()
}
))
}
private func createConnection(targetId:String)->Connection{
let connection = Connection(myId: userId, targetId: targetId) { (streamWrapper) in
print("onAeedStream")
self.remoteLayer.addSubview(streamWrapper.view)
self.streamWrapperList.append(streamWrapper)
self.calcRemoteViewPosition()
}
connectionList.append(connection)
return connection
}
private func findConnection(targetId:String)->Connection?{
for i in 0..<connectionList.count{
let connection = connectionList[i]
if(connection.targetId == targetId){
return connection
}
}
print("not found connection")
return nil
}
private func calcRemoteViewPosition(){
var y:CGFloat = 20.0
var x:CGFloat = 20
for i in 0..<streamWrapperList.count{
let view = streamWrapperList[i].view
view.frame.origin = CGPoint(x: x, y: y)
if x + view.frame.size.width > windowWidth(){
x = 20
y = y + view.frame.size.height
view.frame.origin = CGPoint(x: x, y: y)
}
x = x + view.frame.size.width + 20
}
}
}
次
- Android 複数接続
- 接続切れた時が取れない