使用 IVS iOS 廣播 SDK 發布和訂閱 - Amazon IVS

使用 IVS iOS 廣播 SDK 發布和訂閱

本節將引導您完成使用 iOS 應用程式發布和訂閱階段的相關步驟。

建立檢視

首先,使用自動建立用於匯入 AmazonIVSBroadcast 的 ViewController.swift 檔案,然後新增一些要連結的 @IBOutlets

import AmazonIVSBroadcast class ViewController: UIViewController { @IBOutlet private var textFieldToken: UITextField! @IBOutlet private var buttonJoin: UIButton! @IBOutlet private var labelState: UILabel! @IBOutlet private var switchPublish: UISwitch! @IBOutlet private var collectionViewParticipants: UICollectionView!

現在,建立這些檢視,並在 Main.storyboard 中將其連結。以下是將使用的檢視結構:

使用 Main.storyboard 來建立 iOS 檢視。

若是 AutoLayout 組態,需要自訂三個檢視。第一個檢視是集合檢視參與者 (UICollectionView)。將前導尾隨底部繫結至安全區域。同時將頂部繫結至控制容器

自訂 iOS 集合檢視參與者的檢視。

第二個檢視是控制容器。將前導尾隨頂部繫結至安全區域

自訂 iOS 控制容器檢視。

第三個和最後一個檢視是垂直堆疊檢視。將頂部前導尾隨底部繫結至父檢視。針對樣式,將間距設定為 8,而不是 0。

自訂 iOS 垂直堆疊檢視。

UIStackViews 會處理其餘檢視的配置。針對所有三個 UIStackViews,在對齊分佈時使用填滿

使用 UIStackViews 自訂其餘的 iOS 檢視。

最後,將這些檢視連結至 ViewController。從上方映射下列檢視:

  • 文字欄位加入繫結至 textFieldToken

  • 按鈕加入繫結至 buttonJoin

  • 標籤狀態繫結至 labelState

  • 切換發布繫結至 switchPublish

  • 集合檢視參與者繫結至 collectionViewParticipants

同時還會將集合檢視參與者項目的 dataSource 設定為擁有的 ViewController:

若是 iOS 應用程式,還會設定集合檢視參與者的資料來源。

現在,建立要在其中轉譯參與者的 UICollectionViewCell 子類別。首先,建立一個新的 Cocoa Touch 類別檔案:

建立 UICollectionViewCell,以轉譯 iOS 即時參與者。

將其命名為 ParticipantUICollectionViewCell,然後使其成為 Swift 中 UICollectionViewCell 的子類別。再次從 Swift 檔案開始,建立要連結的 @IBOutlets

import AmazonIVSBroadcast class ParticipantCollectionViewCell: UICollectionViewCell { @IBOutlet private var viewPreviewContainer: UIView! @IBOutlet private var labelParticipantId: UILabel! @IBOutlet private var labelSubscribeState: UILabel! @IBOutlet private var labelPublishState: UILabel! @IBOutlet private var labelVideoMuted: UILabel! @IBOutlet private var labelAudioMuted: UILabel! @IBOutlet private var labelAudioVolume: UILabel!

在關聯的 XIB 檔案中,建立此檢視階層:

在關聯的 XIB 檔案中,建立 iOS 檢視階層。

針對 AutoLayout,將再次修改三個檢視。第一個檢視是檢視預覽容器。將尾隨前導頂部底部設定為參與者集合檢視儲存格

自訂 iOS 檢視預覽容器檢視。

第二個檢視是檢視。將前導頂部設定為參與者集合檢視儲存格,並將該值變更為 4。

自訂 iOS 檢視的檢視。

第三個檢視是堆疊檢視。將尾隨前導頂部底部設定為父檢視,並將該值變更為 4。

自訂 iOS 堆疊檢視的檢視。

許可和閒置計時器

回到 ViewController,將停用系統閒置計時器,以免在使用應用程式時,裝置進入睡眠狀態:

override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Prevent the screen from turning off during a call. UIApplication.shared.isIdleTimerDisabled = true } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) UIApplication.shared.isIdleTimerDisabled = false }

接著,請求系統的攝影機和麥克風許可:

private func checkPermissions() { checkOrGetPermission(for: .video) { [weak self] granted in guard granted else { print("Video permission denied") return } self?.checkOrGetPermission(for: .audio) { [weak self] granted in guard granted else { print("Audio permission denied") return } self?.setupLocalUser() // we will cover this later } } } private func checkOrGetPermission(for mediaType: AVMediaType, _ result: @escaping (Bool) -> Void) { func mainThreadResult(_ success: Bool) { DispatchQueue.main.async { result(success) } } switch AVCaptureDevice.authorizationStatus(for: mediaType) { case .authorized: mainThreadResult(true) case .notDetermined: AVCaptureDevice.requestAccess(for: mediaType) { granted in mainThreadResult(granted) } case .denied, .restricted: mainThreadResult(false) @unknown default: mainThreadResult(false) } }

應用程式狀態

需要設定 collectionViewParticipants 以及之前建立的配置檔案:

override func viewDidLoad() { super.viewDidLoad() // We render everything to exactly the frame, so don't allow scrolling. collectionViewParticipants.isScrollEnabled = false collectionViewParticipants.register(UINib(nibName: "ParticipantCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: "ParticipantCollectionViewCell") }

為了代表每個參與者,我們建立了一個稱為 StageParticipant 的簡單結構。這可包含在 ViewController.swift 檔案中,或建立新的檔案。

import Foundation import AmazonIVSBroadcast struct StageParticipant { let isLocal: Bool var participantId: String? var publishState: IVSParticipantPublishState = .notPublished var subscribeState: IVSParticipantSubscribeState = .notSubscribed var streams: [IVSStageStream] = [] init(isLocal: Bool, participantId: String?) { self.isLocal = isLocal self.participantId = participantId } }

為了追蹤這些參與者,我們將其陣列做為私有屬性保留在 ViewController 中:

private var participants = [StageParticipant]()

此屬性將用於支援之前從分鏡腳本連結的 UICollectionViewDataSource

extension ViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return participants.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ParticipantCollectionViewCell", for: indexPath) as? ParticipantCollectionViewCell { cell.set(participant: participants[indexPath.row]) return cell } else { fatalError("Couldn't load custom cell type 'ParticipantCollectionViewCell'") } } }

若要在加入階段之前查看您自己的預覽,我們會立即建立本機參與者:

override func viewDidLoad() { /* existing UICollectionView code */ participants.append(StageParticipant(isLocal: true, participantId: nil)) }

這會導致在應用程式執行後,代表本機參與者立即轉譯參與者儲存格。

使用者希望能夠在加入階段之前看到自己,因此,接下來我們實作 setupLocalUser() 方法,可從稍早的許可處理程式碼中呼叫。將攝影機和麥克風參考做為 IVSLocalStageStream 物件來存放。

private var streams = [IVSLocalStageStream]() private let deviceDiscovery = IVSDeviceDiscovery() private func setupLocalUser() { // Gather our camera and microphone once permissions have been granted let devices = deviceDiscovery.listLocalDevices() streams.removeAll() if let camera = devices.compactMap({ $0 as? IVSCamera }).first { streams.append(IVSLocalStageStream(device: camera)) // Use a front camera if available. if let frontSource = camera.listAvailableInputSources().first(where: { $0.position == .front }) { camera.setPreferredInputSource(frontSource) } } if let mic = devices.compactMap({ $0 as? IVSMicrophone }).first { streams.append(IVSLocalStageStream(device: mic)) } participants[0].streams = streams participantsChanged(index: 0, changeType: .updated) }

在這裡透過 SDK 找到了裝置的攝影機和麥克風,並將其存放在本機 streams 物件中,然後將第一個參與者 (我們之前建立的本機參與者) 的 streams 陣列指派到 streams。最後,呼叫 index 為 0,以及 changeType 為 updated 的 participantsChanged。該函數是透過良好的動畫更新 UICollectionView 的輔助程式函數。這是呈現的外觀:

private func participantsChanged(index: Int, changeType: ChangeType) { switch changeType { case .joined: collectionViewParticipants?.insertItems(at: [IndexPath(item: index, section: 0)]) case .updated: // Instead of doing reloadItems, just grab the cell and update it ourselves. It saves a create/destroy of a cell // and more importantly fixes some UI flicker. We disable scrolling so the index path per cell // never changes. if let cell = collectionViewParticipants?.cellForItem(at: IndexPath(item: index, section: 0)) as? ParticipantCollectionViewCell { cell.set(participant: participants[index]) } case .left: collectionViewParticipants?.deleteItems(at: [IndexPath(item: index, section: 0)]) } }

別擔心 cell.set;稍後會處理,這就是我們將根據參與者轉譯儲存格內容的地方。

ChangeType 是一個簡單的列舉:

enum ChangeType { case joined, updated, left }

最後,想要追蹤階段是否已建立連線。使用一個簡單的 bool 來追蹤,在其自行更新時,會自動更新 UI。

private var connectingOrConnected = false { didSet { buttonJoin.setTitle(connectingOrConnected ? "Leave" : "Join", for: .normal) buttonJoin.tintColor = connectingOrConnected ? .systemRed : .systemBlue } }

實作階段 SDK

以下是三個以即時功能為基礎的核心概念:階段、策略和轉譯器。設計目標是盡可能減少打造工作產品所需的用戶端邏輯數量。

IVSStageStrategy

IVSStageStrategy 實作很簡單:

extension ViewController: IVSStageStrategy { func stage(_ stage: IVSStage, streamsToPublishForParticipant participant: IVSParticipantInfo) -> [IVSLocalStageStream] { // Return the camera and microphone to be published. // This is only called if `shouldPublishParticipant` returns true. return streams } func stage(_ stage: IVSStage, shouldPublishParticipant participant: IVSParticipantInfo) -> Bool { // Our publish status is based directly on the UISwitch view return switchPublish.isOn } func stage(_ stage: IVSStage, shouldSubscribeToParticipant participant: IVSParticipantInfo) -> IVSStageSubscribeType { // Subscribe to both audio and video for all publishing participants. return .audioVideo } }

總而言之,只有在發布切換按鈕處於「開啟」位置時才會發布,如果要發布,我們會發布之前收集的串流。最後,針對此範例,始終會訂閱其他參與者,同時接收其音訊和影片。

IVSStageRenderer

IVSStageRenderer 實作也非常簡單,但要考慮包含相當多程式碼的函數數量。當 SDK 通知我們有關參與者的變更時,此轉譯器中的一般方法是更新我們的 participants 陣列。在某些情況下,對於本機參與者的處理方式有所不同,因為我們已決定自行管理他們,以便在他們加入之前查看其攝影機預覽。

extension ViewController: IVSStageRenderer { func stage(_ stage: IVSStage, didChange connectionState: IVSStageConnectionState, withError error: Error?) { labelState.text = connectionState.text connectingOrConnected = connectionState != .disconnected } func stage(_ stage: IVSStage, participantDidJoin participant: IVSParticipantInfo) { if participant.isLocal { // If this is the local participant joining the Stage, update the first participant in our array because we // manually added that participant when setting up our preview participants[0].participantId = participant.participantId participantsChanged(index: 0, changeType: .updated) } else { // If they are not local, add them to the array as a newly joined participant. participants.append(StageParticipant(isLocal: false, participantId: participant.participantId)) participantsChanged(index: (participants.count - 1), changeType: .joined) } } func stage(_ stage: IVSStage, participantDidLeave participant: IVSParticipantInfo) { if participant.isLocal { // If this is the local participant leaving the Stage, update the first participant in our array because // we want to keep the camera preview active participants[0].participantId = nil participantsChanged(index: 0, changeType: .updated) } else { // If they are not local, find their index and remove them from the array. if let index = participants.firstIndex(where: { $0.participantId == participant.participantId }) { participants.remove(at: index) participantsChanged(index: index, changeType: .left) } } } func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChange publishState: IVSParticipantPublishState) { // Update the publishing state of this participant mutatingParticipant(participant.participantId) { data in data.publishState = publishState } } func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChange subscribeState: IVSParticipantSubscribeState) { // Update the subscribe state of this participant mutatingParticipant(participant.participantId) { data in data.subscribeState = subscribeState } } func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChangeMutedStreams streams: [IVSStageStream]) { // We don't want to take any action for the local participant because we track those streams locally if participant.isLocal { return } // For remote participants, notify the UICollectionView that they have updated. There is no need to modify // the `streams` property on the `StageParticipant` because it is the same `IVSStageStream` instance. Just // query the `isMuted` property again. if let index = participants.firstIndex(where: { $0.participantId == participant.participantId }) { participantsChanged(index: index, changeType: .updated) } } func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didAdd streams: [IVSStageStream]) { // We don't want to take any action for the local participant because we track those streams locally if participant.isLocal { return } // For remote participants, add these new streams to that participant's streams array. mutatingParticipant(participant.participantId) { data in data.streams.append(contentsOf: streams) } } func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didRemove streams: [IVSStageStream]) { // We don't want to take any action for the local participant because we track those streams locally if participant.isLocal { return } // For remote participants, remove these streams from that participant's streams array. mutatingParticipant(participant.participantId) { data in let oldUrns = streams.map { $0.device.descriptor().urn } data.streams.removeAll(where: { stream in return oldUrns.contains(stream.device.descriptor().urn) }) } } // A helper function to find a participant by its ID, mutate that participant, and then update the UICollectionView accordingly. private func mutatingParticipant(_ participantId: String?, modifier: (inout StageParticipant) -> Void) { guard let index = participants.firstIndex(where: { $0.participantId == participantId }) else { fatalError("Something is out of sync, investigate if this was a sample app or SDK issue.") } var participant = participants[index] modifier(&participant) participants[index] = participant participantsChanged(index: index, changeType: .updated) } }

此程式碼使用延伸,將連線狀態轉換為易讀的文字:

extension IVSStageConnectionState { var text: String { switch self { case .disconnected: return "Disconnected" case .connecting: return "Connecting" case .connected: return "Connected" @unknown default: fatalError() } } }

實作自訂 UICollectionViewLayout

配置不同數量的參與者可能很複雜。您希望他們佔用整個父檢視的框架,但不想單獨處理每個參與者配置。為了簡化這一點,我們將逐步實作 UICollectionViewLayout

建立另一個新檔案 ParticipantCollectionViewLayout.swift,這應當會延伸 UICollectionViewLayout。此類別會使用另一個稱為 StageLayoutCalculator 類別,我們很快會介紹。該類別會接收針對每個參與者計算的影格值,然後產生必要的 UICollectionViewLayoutAttributes 物件。

import Foundation import UIKit /** Code modified from https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts/customizing_collection_view_layouts?language=objc */ class ParticipantCollectionViewLayout: UICollectionViewLayout { private let layoutCalculator = StageLayoutCalculator() private var contentBounds = CGRect.zero private var cachedAttributes = [UICollectionViewLayoutAttributes]() override func prepare() { super.prepare() guard let collectionView = collectionView else { return } cachedAttributes.removeAll() contentBounds = CGRect(origin: .zero, size: collectionView.bounds.size) layoutCalculator.calculateFrames(participantCount: collectionView.numberOfItems(inSection: 0), width: collectionView.bounds.size.width, height: collectionView.bounds.size.height, padding: 4) .enumerated() .forEach { (index, frame) in let attributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0)) attributes.frame = frame cachedAttributes.append(attributes) contentBounds = contentBounds.union(frame) } } override var collectionViewContentSize: CGSize { return contentBounds.size } override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { guard let collectionView = collectionView else { return false } return !newBounds.size.equalTo(collectionView.bounds.size) } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return cachedAttributes[indexPath.item] } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { var attributesArray = [UICollectionViewLayoutAttributes]() // Find any cell that sits within the query rect. guard let lastIndex = cachedAttributes.indices.last, let firstMatchIndex = binSearch(rect, start: 0, end: lastIndex) else { return attributesArray } // Starting from the match, loop up and down through the array until all the attributes // have been added within the query rect. for attributes in cachedAttributes[..<firstMatchIndex].reversed() { guard attributes.frame.maxY >= rect.minY else { break } attributesArray.append(attributes) } for attributes in cachedAttributes[firstMatchIndex...] { guard attributes.frame.minY <= rect.maxY else { break } attributesArray.append(attributes) } return attributesArray } // Perform a binary search on the cached attributes array. func binSearch(_ rect: CGRect, start: Int, end: Int) -> Int? { if end < start { return nil } let mid = (start + end) / 2 let attr = cachedAttributes[mid] if attr.frame.intersects(rect) { return mid } else { if attr.frame.maxY < rect.minY { return binSearch(rect, start: (mid + 1), end: end) } else { return binSearch(rect, start: start, end: (mid - 1)) } } } }

更重要的是 StageLayoutCalculator.swift 類別。設計此類別的目的是,根據以流程為基礎的資料列/資料欄配置中的參與者人數,來計算每個參與者的影格。每個資料列的高度與其他資料列相同,但每個資料列的寬度可能有所差異。請參閱 layouts 變數上方的程式碼註解,描述了如何自訂此行為。

import Foundation import UIKit class StageLayoutCalculator { /// This 2D array contains the description of how the grid of participants should be rendered /// The index of the 1st dimension is the number of participants needed to active that configuration /// Meaning if there is 1 participant, index 0 will be used. If there are 5 participants, index 4 will be used. /// /// The 2nd dimension is a description of the layout. The length of the array is the number of rows that /// will exist, and then each number within that array is the number of columns in each row. /// /// See the code comments next to each index for concrete examples. /// /// This can be customized to fit any layout configuration needed. private let layouts: [[Int]] = [ // 1 participant [ 1 ], // 1 row, full width // 2 participants [ 1, 1 ], // 2 rows, all columns are full width // 3 participants [ 1, 2 ], // 2 rows, first row's column is full width then 2nd row's columns are 1/2 width // 4 participants [ 2, 2 ], // 2 rows, all columns are 1/2 width // 5 participants [ 1, 2, 2 ], // 3 rows, first row's column is full width, 2nd and 3rd row's columns are 1/2 width // 6 participants [ 2, 2, 2 ], // 3 rows, all column are 1/2 width // 7 participants [ 2, 2, 3 ], // 3 rows, 1st and 2nd row's columns are 1/2 width, 3rd row's columns are 1/3rd width // 8 participants [ 2, 3, 3 ], // 9 participants [ 3, 3, 3 ], // 10 participants [ 2, 3, 2, 3 ], // 11 participants [ 2, 3, 3, 3 ], // 12 participants [ 3, 3, 3, 3 ], ] // Given a frame (this could be for a UICollectionView, or a Broadcast Mixer's canvas), calculate the frames for each // participant, with optional padding. func calculateFrames(participantCount: Int, width: CGFloat, height: CGFloat, padding: CGFloat) -> [CGRect] { if participantCount > layouts.count { fatalError("Only \(layouts.count) participants are supported at this time") } if participantCount == 0 { return [] } var currentIndex = 0 var lastFrame: CGRect = .zero // If the height is less than the width, the rows and columns will be flipped. // Meaning for 6 participants, there will be 2 rows of 3 columns each. let isVertical = height > width let halfPadding = padding / 2.0 let layout = layouts[participantCount - 1] // 1 participant is in index 0, so `-1`. let rowHeight = (isVertical ? height : width) / CGFloat(layout.count) var frames = [CGRect]() for row in 0 ..< layout.count { // layout[row] is the number of columns in a layout let itemWidth = (isVertical ? width : height) / CGFloat(layout[row]) let segmentFrame = CGRect(x: (isVertical ? 0 : lastFrame.maxX) + halfPadding, y: (isVertical ? lastFrame.maxY : 0) + halfPadding, width: (isVertical ? itemWidth : rowHeight) - padding, height: (isVertical ? rowHeight : itemWidth) - padding) for column in 0 ..< layout[row] { var frame = segmentFrame if isVertical { frame.origin.x = (itemWidth * CGFloat(column)) + halfPadding } else { frame.origin.y = (itemWidth * CGFloat(column)) + halfPadding } frames.append(frame) currentIndex += 1 } lastFrame = segmentFrame lastFrame.origin.x += halfPadding lastFrame.origin.y += halfPadding } return frames } }

回到 Main.storyboard,請務必將 UICollectionView 的配置類別設定為我們剛才建立的類別:

Xcode interface showing storyboard with UICollectionView and its layout settings.

掛接 UI 動作

我們即將結束,只需建立幾個 IBActions

首先,將處理加入按鈕。其反應因 connectingOrConnected 的值而異。一旦建立連線後,便會離開階段。如果中斷連線,則會從權杖 UITextField 讀取文字,並建立具有該文字的新的 IVSStage。然後,新增 ViewController 做為 strategyerrorDelegate 和用於 IVSStage 的轉譯器,最後,我們以非同步方式加入階段。

@IBAction private func joinTapped(_ sender: UIButton) { if connectingOrConnected { // If we're already connected to a Stage, leave it. stage?.leave() } else { guard let token = textFieldToken.text else { print("No token") return } // Hide the keyboard after tapping Join textFieldToken.resignFirstResponder() do { // Destroy the old Stage first before creating a new one. self.stage = nil let stage = try IVSStage(token: token, strategy: self) stage.errorDelegate = self stage.addRenderer(self) try stage.join() self.stage = stage } catch { print("Failed to join stage - \(error)") } } }

需要掛接的另一個 UI 動作是發布切換按鈕:

@IBAction private func publishToggled(_ sender: UISwitch) { // Because the strategy returns the value of `switchPublish.isOn`, just call `refreshStrategy`. stage?.refreshStrategy() }

轉譯參與者

最後,我們需要將從 SDK 接收的資料,轉譯到之前建立的參與者儲存格上。我們已經完成了 UICollectionView 邏輯,因此只需實作 ParticipantCollectionViewCell.swift 中的 set API。

從新增 empty 函數開始,然後逐步實作:

func set(participant: StageParticipant) { }

首先,處理簡易狀態、參與者 ID、發布狀態和訂閱狀態。對於這些,只需直接更新 UILabels

labelParticipantId.text = participant.isLocal ? "You (\(participant.participantId ?? "Disconnected"))" : participant.participantId labelPublishState.text = participant.publishState.text labelSubscribeState.text = participant.subscribeState.text

發布和訂閱列舉的文字屬性源自本機延伸:

extension IVSParticipantPublishState { var text: String { switch self { case .notPublished: return "Not Published" case .attemptingPublish: return "Attempting to Publish" case .published: return "Published" @unknown default: fatalError() } } } extension IVSParticipantSubscribeState { var text: String { switch self { case .notSubscribed: return "Not Subscribed" case .attemptingSubscribe: return "Attempting to Subscribe" case .subscribed: return "Subscribed" @unknown default: fatalError() } } }

接著,更新音訊和影片靜音狀態。為取得靜音狀態,需要從 streams 陣列中找到 IVSImageDevice 和 IVSAudioDevice。為優化效能,會記住上次連接的裝置。

// This belongs outside `set(participant:)` private var registeredStreams: Set<IVSStageStream> = [] private var imageDevice: IVSImageDevice? { return registeredStreams.lazy.compactMap { $0.device as? IVSImageDevice }.first } private var audioDevice: IVSAudioDevice? { return registeredStreams.lazy.compactMap { $0.device as? IVSAudioDevice }.first } // This belongs inside `set(participant:)` let existingAudioStream = registeredStreams.first { $0.device is IVSAudioDevice } let existingImageStream = registeredStreams.first { $0.device is IVSImageDevice } registeredStreams = Set(participant.streams) let newAudioStream = participant.streams.first { $0.device is IVSAudioDevice } let newImageStream = participant.streams.first { $0.device is IVSImageDevice } // `isMuted != false` covers the stream not existing, as well as being muted. labelVideoMuted.text = "Video Muted: \(newImageStream?.isMuted != false)" labelAudioMuted.text = "Audio Muted: \(newAudioStream?.isMuted != false)"

最後,要轉譯 imageDevice 的預覽,並顯示 audioDevice 的音訊統計資料:

if existingImageStream !== newImageStream { // The image stream has changed updatePreview() // We’ll cover this next } if existingAudioStream !== newAudioStream { (existingAudioStream?.device as? IVSAudioDevice)?.setStatsCallback(nil) audioDevice?.setStatsCallback( { [weak self] stats in self?.labelAudioVolume.text = String(format: "Audio Level: %.0f dB", stats.rms) }) // When the audio stream changes, it will take some time to receive new stats. Reset the value temporarily. self.labelAudioVolume.text = "Audio Level: -100 dB" }

我們需要建立的最後一個函數是 updatePreview(),這會將參與者的預覽新增至檢視:

private func updatePreview() { // Remove any old previews from the preview container viewPreviewContainer.subviews.forEach { $0.removeFromSuperview() } if let imageDevice = self.imageDevice { if let preview = try? imageDevice.previewView(with: .fit) { viewPreviewContainer.addSubviewMatchFrame(preview) } } }

上面使用了 UIView 上的輔助程式函數,使內嵌子檢視變得更容易:

extension UIView { func addSubviewMatchFrame(_ view: UIView) { view.translatesAutoresizingMaskIntoConstraints = false self.addSubview(view) NSLayoutConstraint.activate([ view.topAnchor.constraint(equalTo: self.topAnchor, constant: 0), view.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0), view.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 0), view.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0), ]) } }