| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613 |
- //
- // MultiStreamView.swift
- // TUIRoomKit
- //
- // Created by CY zhao on 2024/10/22.
- //
- import Foundation
- import RTCRoomEngine
- import UIKit
- import Factory
- import Combine
- #if canImport(TXLiteAVSDK_TRTC)
- import TXLiteAVSDK_TRTC
- #elseif canImport(TXLiteAVSDK_Professional)
- import TXLiteAVSDK_Professional
- #endif
- struct DataChanges {
- let deletions: [IndexPath]
- let insertions: [IndexPath]
- let moves: [(from: IndexPath, to: IndexPath)]
-
- var hasChanges: Bool {
- return !deletions.isEmpty || !insertions.isEmpty || !moves.isEmpty
- }
- }
- protocol MultiStreamViewDelegate: AnyObject {
- func multiStreamViewDidScroll(_ scrollView: UIScrollView)
- func multiStreamViewDidSwitchToSpeechLayout(_ collectionView: UICollectionView)
- func multiStreamViewDidSwitchToGridLayout(_ collectionView: UICollectionView)
- }
- class MultiStreamView: UIView {
- @Injected(\.videoStore) var videoStore: VideoStore
- private var currentLayoutConfig: VideoLayoutConfig
- private var defaultLayoutConfig: VideoLayoutConfig
- private let CellID_Normal = "MultiStreamCell_Normal"
- private var isViewReady: Bool = false
- private var cancellables = Set<AnyCancellable>()
- @Published private(set) var dataSource: [UserInfo] = []
- @Published private var excludeVideoItems: [UserInfo] = []
- @Published private var speechItem: UserInfo?
- weak var delegate: MultiStreamViewDelegate?
-
- var engineManager: EngineManager {
- EngineManager.shared
- }
-
- // TODO: remove roomstore
- var roomStore: RoomStore {
- EngineManager.shared.store
- }
- var currentUserId: String {
- roomStore.currentUser.userId
- }
-
- private var itemStreamType: TUIVideoStreamType {
- if videoStore.videoState.videoSeatItems.filter({ $0.hasVideoStream }).count > 5 {
- return .cameraStreamLow
- } else {
- return .cameraStream
- }
- }
-
- init(maxRows: Int, maxColumns: Int) {
- let config = VideoLayoutConfig(maxRows: maxRows, maxColumns: maxColumns, spacing: 5.0, aspectRatio: 0)
- self.currentLayoutConfig = config
- self.defaultLayoutConfig = config
- super.init(frame: .zero)
- setupBindings()
- }
-
- convenience init() {
- self.init(maxRows: 2, maxColumns: 3)
- }
-
- var gridContentOffset: CGPoint {
- return attendeeCollectionView.contentOffset
- }
-
- var streamSorter: MultiStreamsSorter {
- return MultiStreamsSorter(currentUserId: roomStore.currentUser.userId)
- }
-
- func setExcludeVideoItems(items: [UserInfo]) {
- self.excludeVideoItems = items
- }
-
- func setSpeechItem(item: UserInfo?) {
- self.speechItem = item
- }
-
- func reset() {
- self.setSpeechItem(item: nil)
- self.excludeVideoItems = []
- }
-
- private func setupBindings() {
- let speechItemChanges = $speechItem
-
- speechItemChanges
- .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
- .sink { [weak self] speechItem in
- guard let self = self else { return }
- if let layout = attendeeCollectionView.collectionViewLayout as? MultiStreamViewLayout {
- layout.isSpeechMode = speechItem != nil
- }
- self.attendeeCollectionView.collectionViewLayout.invalidateLayout()
- }
- .store(in: &cancellables)
-
- Publishers.CombineLatest3(
- videoStore.subscribe(Selector(keyPath: \VideoSeatState.videoSeatItems)).removeDuplicates(),
- $excludeVideoItems,
- speechItemChanges
- )
- .map { [weak self] videoItems, excludeInfos, speechItem -> [UserInfo] in
- guard let self = self else { return videoItems }
- let sortedItems = streamSorter.sortStreams(videoItems)
- var items = sortedItems.filter { item in
- let excluded = excludeInfos.contains(where: { $0.userId == item.userId })
- let notSpeechItem = speechItem.map { speech in
- !(item.userId == speech.userId &&
- item.videoStreamType == speech.videoStreamType)
- } ?? true
- return !excluded && notSpeechItem
- }
- if let speechItem = speechItem {
- items.insert(speechItem, at: 0)
- }
- return items
- }
- .removeDuplicates()
- .receive(on: RunLoop.main)
- .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
- .sink { [weak self] videoItems in
- guard let self = self else { return }
- self.handleDataChanged(videoItems)
- }
- .store(in: &cancellables)
- }
-
- private func handleDataChanged(_ newItems: [UserInfo]) {
- let changes = calculateChanges(old: dataSource, new: newItems)
-
- guard changes.hasChanges else {return}
-
- freshCollectionView { [ weak self] in
- guard let self = self else { return }
- self.attendeeCollectionView.performBatchUpdates {
- self.dataSource = newItems
- self.attendeeCollectionView.deleteItems(at: changes.deletions)
- self.attendeeCollectionView.insertItems(at: changes.insertions)
- changes.moves.forEach { move in
- self.attendeeCollectionView.moveItem(at: move.from, to: move.to)
- }
- }
- }
- }
-
- private var pageControl: UIPageControl = {
- let control = UIPageControl()
- control.currentPage = 0
- control.numberOfPages = 1
- control.hidesForSinglePage = true
- control.isUserInteractionEnabled = false
- return control
- }()
-
- override func didMoveToWindow() {
- super.didMoveToWindow()
- guard !isViewReady else { return }
- constructViewHierarchy()
- activateConstraints()
- bindInteraction()
- isViewReady = true
- }
-
- override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- super.traitCollectionDidChange(previousTraitCollection)
-
- if traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass ||
- traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass {
- let offsetYu = Int(attendeeCollectionView.contentOffset.x) % Int(attendeeCollectionView.mm_w)
- let offsetMuti = CGFloat(offsetYu) / attendeeCollectionView.mm_w
- let currentPage = (offsetMuti > 0.5 ? 1 : 0) + (Int(attendeeCollectionView.contentOffset.x) / Int(attendeeCollectionView.mm_w))
- attendeeCollectionView.setContentOffset(
- CGPoint(x: CGFloat(pageControl.currentPage) * attendeeCollectionView.frame.size.width,
- y: attendeeCollectionView.contentOffset.y), animated: false)
- }
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- lazy var attendeeCollectionView: UICollectionView = {
- let layout = MultiStreamViewLayout(config: currentLayoutConfig)
- layout.delegate = self
- let collection = AttendeeListView(frame: CGRect(x: 0, y: 0, width: frame.size.width, height: frame.size.height), collectionViewLayout: layout)
- collection.register(MultiStreamCell.self, forCellWithReuseIdentifier: CellID_Normal)
- collection.isPagingEnabled = true
- collection.showsVerticalScrollIndicator = false
- collection.showsHorizontalScrollIndicator = false
- collection.isUserInteractionEnabled = true
- collection.contentMode = .scaleToFill
- collection.backgroundColor = UIColor(0x0F1014)
- if #available(iOS 11.0, *) {
- collection.contentInsetAdjustmentBehavior = .never
- } else {
- // Fallback on earlier versions
- }
- if #available(iOS 10.0, *) {
- collection.isPrefetchingEnabled = true
- } else {
- // Fallback on earlier versions
- }
- collection.dataSource = self
- collection.delegate = self
- collection.layoutDelegate = self
- return collection
- }()
-
- let placeholderView: UIView = {
- let view = UIView(frame: .zero)
- view.isHidden = true
- return view
- }()
-
- func constructViewHierarchy() {
- backgroundColor = .clear
- addSubview(placeholderView)
- addSubview(attendeeCollectionView)
- addSubview(pageControl)
- }
-
- func activateConstraints() {
- placeholderView.snp.makeConstraints { make in
- make.edges.equalToSuperview()
- }
- attendeeCollectionView.snp.makeConstraints { make in
- make.edges.equalToSuperview()
- }
- pageControl.snp.makeConstraints { make in
- make.height.equalTo(24)
- make.centerX.equalToSuperview()
- make.bottom.equalToSuperview().offset(-5)
- }
- }
-
- func bindInteraction() {
- addGesture()
- }
-
- private func addGesture() {
- let tap = UITapGestureRecognizer(target: self, action: #selector(clickVideoSeat))
- addGestureRecognizer(tap)
- }
-
- @objc private func clickVideoSeat() {
- EngineEventCenter.shared.notifyUIEvent(key: .TUIRoomKitService_ChangeToolBarHiddenState, param: [:])
- guard RoomRouter.shared.hasChatWindow() else { return }
- EngineEventCenter.shared.notifyUIEvent(key: .TUIRoomKitService_HiddenChatWindow, param: [:])
- }
-
- func updatePageControl() {
- let offsetYu = Int(attendeeCollectionView.contentOffset.x) % Int(attendeeCollectionView.mm_w)
- let offsetMuti = CGFloat(offsetYu) / attendeeCollectionView.mm_w
- pageControl.currentPage = (offsetMuti > 0.5 ? 1 : 0) + (Int(attendeeCollectionView.contentOffset.x) / Int(attendeeCollectionView.mm_w))
-
- let cellArray = attendeeCollectionView.visibleCells
- for cell in cellArray {
- if let seatCell = cell as? MultiStreamCell, let seatItem = seatCell.videoItem {
- if pageControl.currentPage != 0 && seatItem.hasVideoStream {
- self.startPlayVideoStream(item: seatItem, renderView: seatCell.renderView)
- }
- }
- }
- }
-
- func updateLayout(_ config: VideoLayoutConfig, animated: Bool = true) {
- let layout = MultiStreamViewLayout(config: config)
- currentLayoutConfig = config
- if animated {
- UIView.animate(withDuration: 0.3) {
- self.attendeeCollectionView.setCollectionViewLayout(layout, animated: true)
- self.updateCollectionViewBehavior()
- self.updatePageControlState()
- }
- } else {
- attendeeCollectionView.setCollectionViewLayout(layout, animated: false)
- updateCollectionViewBehavior()
- updatePageControlState()
- }
- }
-
- private func updateCollectionViewBehavior() {
- attendeeCollectionView.isPagingEnabled = currentLayoutConfig.isPagingEnable
- attendeeCollectionView.alwaysBounceHorizontal = currentLayoutConfig.isHorizontalScroll
- attendeeCollectionView.alwaysBounceVertical = currentLayoutConfig.isVerticalScroll
- }
-
- private func updatePageControlState() {
- if currentLayoutConfig.isPagingEnable {
- let itemsPerPage = currentLayoutConfig.maxRows * currentLayoutConfig.maxColumns
- let numberOfPages = Int(ceil(Double(dataSource.count) / Double(itemsPerPage)))
- pageControl.isHidden = numberOfPages <= 1
- pageControl.numberOfPages = numberOfPages
- } else {
- pageControl.isHidden = true
- }
- }
-
- deinit {
- debugPrint("deinit \(self)")
- }
- }
- // MARK: - TUIVideoSeatViewModelResponder
- extension MultiStreamView {
- private func calculateChanges(old: [UserInfo], new: [UserInfo]) -> DataChanges {
- var deletions: [IndexPath] = []
- var insertions: [IndexPath] = []
- var moves: [(from: IndexPath, to: IndexPath)] = []
-
- let oldKeys = old.map { "\($0.userId)_\($0.videoStreamType.rawValue)" }
- let newKeys = new.map { "\($0.userId)_\($0.videoStreamType.rawValue)" }
-
- let oldKeyToIndex = Dictionary(uniqueKeysWithValues: oldKeys.enumerated().map { ($1, $0) })
- let newKeyToIndex = Dictionary(uniqueKeysWithValues: newKeys.enumerated().map { ($1, $0) })
-
- let deletedKeys = Set(oldKeys).subtracting(newKeys)
- let insertedKeys = Set(newKeys).subtracting(oldKeys)
- let retainedKeys = Set(oldKeys).intersection(newKeys)
-
- deletions = deletedKeys.compactMap { oldKeyToIndex[$0] }.map { IndexPath(item: $0, section: 0) }.sorted { $0.item > $1.item }
- insertions = insertedKeys.compactMap { newKeyToIndex[$0] }.map { IndexPath(item: $0, section: 0) }.sorted { $0.item < $1.item }
-
- var processedIndices = Set<String>()
- for key in retainedKeys {
- guard let oldIndex = oldKeyToIndex[key],
- let newIndex = newKeyToIndex[key],
- oldIndex != newIndex,
- !processedIndices.contains(key) else {
- continue
- }
- let fromPath = IndexPath(item: oldIndex, section: 0)
- let toPath = IndexPath(item: newIndex, section: 0)
- if !deletions.contains(fromPath) && !insertions.contains(toPath) {
- moves.append((from: fromPath, to: toPath))
- processedIndices.insert(key)
- }
- }
- moves.sort { $0.from.item < $1.from.item }
-
- return DataChanges(
- deletions: deletions,
- insertions: insertions,
- moves: moves
- )
- }
-
- private func freshCollectionView(block: () -> Void) {
- CATransaction.begin()
- CATransaction.setDisableActions(true)
- block()
- CATransaction.commit()
- }
-
- func reloadData() {
- freshCollectionView { [weak self] in
- guard let self = self else { return }
- self.attendeeCollectionView.reloadData()
- }
- }
-
- func getVideoVisibleCell(_ item: VideoSeatItem) -> VideoSeatCell? {
- let cellArray = attendeeCollectionView.visibleCells
- guard let cell = cellArray.first(where: { cell in
- if let seatCell = cell as? VideoSeatCell, seatCell.seatItem == item {
- return true
- } else {
- return false
- }
- }) as? VideoSeatCell else { return nil }
- return cell
- }
- }
- // MARK: - UICollectionViewDelegateFlowLayout
- extension MultiStreamView: UICollectionViewDelegateFlowLayout {
- func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
- guard let seatCell = cell as? MultiStreamCell else { return }
- guard let dataItem = dataSource[safe: indexPath.item] else { return }
- guard let seatItem = seatCell.videoItem else { return }
- if dataItem.userId != seatItem.userId {
- seatCell.reset()
- seatCell.updateUI(item: dataItem)
- bindVideoState(cell: seatCell, with: dataItem)
- } else {
- if seatItem.hasVideoStream || seatItem.videoStreamType == .screenStream {
- self.startPlayVideoStream(item: seatItem, renderView: seatCell.renderView)
- } else {
- self.stopPlayVideoStream(item: seatItem)
- }
- }
- }
-
- func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
- guard let seatCell = cell as? MultiStreamCell else { return }
- if let seatItem = seatCell.videoItem {
- self.stopPlayVideoStream(item: seatItem)
- }
- }
- }
- extension MultiStreamView: UIScrollViewDelegate {
- func scrollViewDidScroll(_ scrollView: UIScrollView) {
- delegate?.multiStreamViewDidScroll(scrollView)
- }
-
- func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
- updatePageControl()
- }
- }
- // MARK: - UICollectionViewDataSource
- extension MultiStreamView: UICollectionViewDataSource {
- func numberOfSections(in collectionView: UICollectionView) -> Int {
- return 1
- }
-
- func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
- return dataSource.count
- }
-
-
- func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
- guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier:CellID_Normal, for: indexPath)
- as? MultiStreamCell else {
- return UICollectionViewCell()
- }
- if let item = dataSource[safe: indexPath.item] {
- cell.updateUI(item: item)
- bindVideoState(cell:cell, with: item)
- }
- return cell
- }
-
- private func bindVideoState(cell: MultiStreamCell, with item: UserInfo) {
- let userId = item.userId
- let videoItemPublisher = createVideoItemPublisher(for: userId, item: item)
-
- videoItemPublisher
- .receive(on: RunLoop.main)
- .dropFirst()
- .removeDuplicates { oldItem, newItem in
- oldItem.hasAudioStream == newItem.hasAudioStream &&
- oldItem.userVoiceVolume == newItem.userVoiceVolume
- }
- .sink { [weak cell] item in
- guard let cell = cell else { return }
- cell.updateUIVolume(item: item)
- }
- .store(in: &cell.cancellableSet)
-
- videoItemPublisher
- .receive(on: RunLoop.main)
- .removeDuplicates { oldItem, newItem in
- oldItem.hasVideoStream == newItem.hasVideoStream &&
- oldItem.videoStreamType == newItem.videoStreamType
- }
- .sink { [weak cell, weak self] item in
- guard let cell = cell else { return }
- guard let self = self else { return }
- if isCellVisible(cell) {
- if item.hasVideoStream || item.videoStreamType == .screenStream {
- self.startPlayVideoStream(item: item, renderView: cell.renderView)
- } else {
- self.stopPlayVideoStream(item: item)
- }
- }
-
- cell.updateUI(item: item)
- }
- .store(in: &cell.cancellableSet)
-
- videoItemPublisher
- .receive(on: RunLoop.main)
- .removeDuplicates { oldItem, newItem in
- oldItem.userName == newItem.userName &&
- oldItem.userRole == newItem.userRole
- }
- .sink { [weak cell] item in
- guard let cell = cell else { return }
- cell.updateUI(item: item)
- }
- .store(in: &cell.cancellableSet)
- }
-
- func createVideoItemPublisher(for userId: String, item: UserInfo) -> AnyPublisher<UserInfo, Never> {
- let seatItemsPublisher = videoStore.subscribe(Selector(keyPath: \VideoSeatState.videoSeatItems))
- .map { items -> UserInfo? in
- items.first { $0.userId == userId}
- }
- .eraseToAnyPublisher()
-
- let shareItemPublisher = videoStore.subscribe(Selector(keyPath: \VideoSeatState.shareItem))
- .map { shareItem -> UserInfo? in
- if let shareItem = shareItem, shareItem.userId == userId {
- return shareItem
- }
- return nil
- }
- .eraseToAnyPublisher()
-
- return (item.videoStreamType == .screenStream ? shareItemPublisher : seatItemsPublisher)
- .compactMap { $0 }
- .receive(on: RunLoop.main)
- .share()
- .eraseToAnyPublisher()
- }
-
- private func isCellVisible(_ cell: UICollectionViewCell) -> Bool {
- let visibleCells = attendeeCollectionView.visibleCells
- return visibleCells.contains(cell)
- }
- }
- // MARK: - UICollectionViewDataSource
- extension MultiStreamView {
- func startPlayVideoStream(item: UserInfo, renderView: UIView?) {
- guard let renderView = renderView else { return }
- var item = item
- if item.userId != currentUserId {
- item.videoStreamType = item.videoStreamType == .screenStream ? .screenStream : itemStreamType
- engineManager.setRemoteVideoView(userId: item.userId, streamType: item.videoStreamType, view: renderView)
- engineManager.startPlayRemoteVideo(userId: item.userId, streamType: item.videoStreamType)
- } else if item.hasVideoStream {
- engineManager.setLocalVideoView(renderView)
- }
- }
-
- func stopPlayVideoStream(item: UserInfo) {
- if item.userId == currentUserId {
- engineManager.setLocalVideoView(nil)
- } else {
- engineManager.setRemoteVideoView(userId: item.userId, streamType: item.videoStreamType, view: nil)
- if item.videoStreamType == .screenStream {
- engineManager.stopPlayRemoteVideo(userId: item.userId, streamType: .screenStream)
- } else {
- engineManager.stopPlayRemoteVideo(userId: item.userId, streamType: .cameraStream)
- engineManager.stopPlayRemoteVideo(userId: item.userId, streamType: .cameraStreamLow)
- }
- }
- }
- }
- extension MultiStreamView: MultiStreamLayoutDelegate {
- func updateNumberOfPages(numberOfPages: NSInteger) {
- pageControl.numberOfPages = numberOfPages
- }
- }
- extension MultiStreamView: AttendeeListViewDelegate {
- func attendListDidSwitchToFullScreenLayout(_ attendeeList: UICollectionView) {
- delegate?.multiStreamViewDidSwitchToSpeechLayout(attendeeList)
- }
-
- func attendListDidSwitchToGridLayout(_ attendeeList: UICollectionView) {
- delegate?.multiStreamViewDidSwitchToGridLayout(attendeeList)
- }
- }
- fileprivate protocol AttendeeListViewDelegate: AnyObject {
- func attendListDidSwitchToFullScreenLayout(_ attendeeList: UICollectionView)
- func attendListDidSwitchToGridLayout(_ attendeeList: UICollectionView)
- }
- fileprivate class AttendeeListView: UICollectionView {
- weak var layoutDelegate: AttendeeListViewDelegate?
- override func layoutSubviews() {
- super.layoutSubviews()
- if isSwitchToFullScreenLayout() {
- handleFullScreenLayout()
- } else {
- handleGridLayout()
- }
- }
- private func isSwitchToFullScreenLayout() -> Bool {
- if let indexPath = indexPathForItem(at: CGPoint(x: contentOffset.x, y: contentOffset.y)),
- indexPath.section == 0, indexPath.item == 0 {
- return true
- }
- return false
- }
- private func handleFullScreenLayout() {
- self.layoutDelegate?.attendListDidSwitchToFullScreenLayout(self)
- }
- private func handleGridLayout() {
- self.layoutDelegate?.attendListDidSwitchToGridLayout(self)
- }
- }
|