From e1f1ff0804706c95eaa584d78da6b1e941357c6d Mon Sep 17 00:00:00 2001 From: Aravind Raveendran Date: Mon, 8 Jan 2024 00:36:03 +1100 Subject: [PATCH] Add PiP support --- .../Model/StreamSourceViewRenderer.swift | 25 +- .../Private/Managers/PiPManager.swift | 70 +++++ .../Private/ViewModels/StreamViewModel.swift | 239 ++++++++++++++---- .../Private/Views/GridView/GridView.swift | 17 +- .../Views/GridView/GridViewModel.swift | 8 +- .../Private/Views/ListView/ListView.swift | 27 +- .../Views/ListView/ListViewModel.swift | 11 +- .../Views/SingleStream/SingleStreamView.swift | 3 +- .../SingleStream/SingleStreamViewModel.swift | 5 +- .../VideoRenderer/VideoRendererView.swift | 89 ++++--- .../VideoRendererViewModel.swift | 3 + .../Configurations/AppConfigurations.swift | 3 + .../Screens/Media/StreamingScreen.swift | 13 +- 13 files changed, 382 insertions(+), 131 deletions(-) create mode 100644 Sources/DolbyIORTSUIKit/Private/Managers/PiPManager.swift diff --git a/Sources/DolbyIORTSCore/Model/StreamSourceViewRenderer.swift b/Sources/DolbyIORTSCore/Model/StreamSourceViewRenderer.swift index 4058dd4..505655e 100644 --- a/Sources/DolbyIORTSCore/Model/StreamSourceViewRenderer.swift +++ b/Sources/DolbyIORTSCore/Model/StreamSourceViewRenderer.swift @@ -12,16 +12,29 @@ public class StreamSourceViewRenderer: Identifiable { static let defaultVideoTileSize = CGSize(width: 533, height: 300) } - private let renderer: MCIosVideoRenderer - - let videoTrack: MCVideoTrack - + public let streamSource: StreamSource + public let videoTrack: MCVideoTrack + public let playbackView: MCSampleBufferVideoUIView + public let pipView: MCSampleBufferVideoUIView public let id = UUID() + private let renderer: MCIosVideoRenderer + public init(_ streamSource: StreamSource) { + self.streamSource = streamSource let videoTrack = streamSource.videoTrack.track self.renderer = MCIosVideoRenderer() self.videoTrack = videoTrack + + let playbackView = MCSampleBufferVideoUIView() + playbackView.scalingMode = .aspectFit + playbackView.attach(videoTrack: videoTrack, mirrored: false) + self.playbackView = playbackView + + let pipView = MCSampleBufferVideoUIView() + pipView.scalingMode = .aspectFit + pipView.attach(videoTrack: videoTrack, mirrored: false) + self.pipView = pipView Task { await MainActor.run { @@ -37,10 +50,6 @@ public class StreamSourceViewRenderer: Identifiable { public var frameHeight: CGFloat { hasValidDimensions ? CGFloat(renderer.getHeight()) : Constants.defaultVideoTileSize.height } - - public var playbackView: UIView { - renderer.getView() - } } // MARK: Helper functions diff --git a/Sources/DolbyIORTSUIKit/Private/Managers/PiPManager.swift b/Sources/DolbyIORTSUIKit/Private/Managers/PiPManager.swift new file mode 100644 index 0000000..85b40e4 --- /dev/null +++ b/Sources/DolbyIORTSUIKit/Private/Managers/PiPManager.swift @@ -0,0 +1,70 @@ +// +// PiPManager.swift +// + +import AVFoundation +import AVKit +import Foundation +import MillicastSDK +import UIKit + +final class PiPManager: NSObject { + static let shared: PiPManager = PiPManager() + + private override init() {} + + private(set) var pipController: AVPictureInPictureController? + private(set) var pipView: MCSampleBufferVideoUIView? + + func set(pipView: MCSampleBufferVideoUIView, with targetView: UIView) { + pipController?.stopPictureInPicture() + + guard AVPictureInPictureController.isPictureInPictureSupported() else { + return + } + + let pipVideoCallViewController = AVPictureInPictureVideoCallViewController() + pipVideoCallViewController.view.addSubview(pipView) + pipView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + pipVideoCallViewController.view.topAnchor.constraint(equalTo: pipView.topAnchor), + pipVideoCallViewController.view.leadingAnchor.constraint(equalTo: pipView.leadingAnchor), + pipView.bottomAnchor.constraint(equalTo: pipVideoCallViewController.view.bottomAnchor), + pipView.trailingAnchor.constraint(equalTo: pipVideoCallViewController.view.trailingAnchor) + ]) + pipVideoCallViewController.preferredContentSize = targetView.frame.size + + let pipContentSource = AVPictureInPictureController.ContentSource( + activeVideoCallSourceView: targetView, + contentViewController: pipVideoCallViewController + ) + + let pipController = AVPictureInPictureController(contentSource: pipContentSource) + pipController.canStartPictureInPictureAutomaticallyFromInline = true + pipController.delegate = self + + NotificationCenter.default + .addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + self?.stopPiP() + } + + self.pipView = pipView + self.pipController = pipController + } + + func stopPiP() { + pipController?.stopPictureInPicture() + } +} + +extension PiPManager: AVPictureInPictureControllerDelegate { + func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + } + + func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { + } +} diff --git a/Sources/DolbyIORTSUIKit/Private/ViewModels/StreamViewModel.swift b/Sources/DolbyIORTSUIKit/Private/ViewModels/StreamViewModel.swift index 027f765..e1c2786 100644 --- a/Sources/DolbyIORTSUIKit/Private/ViewModels/StreamViewModel.swift +++ b/Sources/DolbyIORTSUIKit/Private/ViewModels/StreamViewModel.swift @@ -5,6 +5,9 @@ import Combine import DolbyIORTSCore import Foundation +import MillicastSDK +import SwiftUI +import UIKit // swiftlint:disable type_body_length final class StreamViewModel: ObservableObject { @@ -23,7 +26,8 @@ final class StreamViewModel: ObservableObject { sources: _, selectedVideoSource: _, selectedAudioSource: _, - settings: _ + settings: _, + detailSingleStreamViewModel: _ ): self = .success(displayMode: displayMode) case let .error(errorViewModel): @@ -39,12 +43,22 @@ final class StreamViewModel: ObservableObject { sources: [StreamSource], selectedVideoSource: StreamSource, selectedAudioSource: StreamSource?, - settings: StreamSettings + settings: StreamSettings, + detailSingleStreamViewModel: SingleStreamViewModel? ) case error(ErrorViewModel) } - enum DisplayMode { + enum DisplayMode: Equatable { + static func == (lhs: StreamViewModel.DisplayMode, rhs: StreamViewModel.DisplayMode) -> Bool { + switch (lhs, rhs) { + case (.grid, .grid), (.list, .list), (.single, .single): + return true + default: + return false + } + } + case single(SingleStreamViewModel) case list(ListViewModel) case grid(GridViewModel) @@ -53,12 +67,30 @@ final class StreamViewModel: ObservableObject { private let settingsManager: SettingsManager private let streamOrchestrator: StreamOrchestrator private var subscriptions: [AnyCancellable] = [] + private var timer: Timer? let streamDetail: StreamDetail let settingsMode: SettingsMode let listViewPrimaryVideoQuality: VideoQuality + + private let singleViewRendererProvider: ViewRendererProvider = .init() + private let gridViewRendererProvider: ViewRendererProvider = .init() + private let listViewMainRendererProvider: ViewRendererProvider = .init() + private let listViewThumbnailRendererProvider: ViewRendererProvider = .init() @Published private(set) var state: State = .loading + @Published var isShowingDetailSingleViewScreen: Bool = false { + didSet { + guard + isShowingDetailSingleViewScreen != oldValue, + let selectedVideoSource = internalState.selectedVideoSource + else { + return + } + + selectVideoSource(selectedVideoSource) + } + } private var internalState: InternalState = .loading { didSet { @@ -69,6 +101,11 @@ final class StreamViewModel: ObservableObject { newlySelectedAudioSource.id != oldValue.selectedAudioSource?.id { playAudio(for: newlySelectedAudioSource) } + + // Stop PiP when there is no video streams + if !internalState.isShowingVideoStreams { + stopPiP() + } } } @@ -79,7 +116,8 @@ final class StreamViewModel: ObservableObject { sources: existingSources, selectedVideoSource: _, selectedAudioSource: _, - settings: _ + settings: _, + detailSingleStreamViewModel: _ ): return existingSources default: @@ -106,25 +144,13 @@ final class StreamViewModel: ObservableObject { switch internalState { case let .success( displayMode: _, - sources: sources, - selectedVideoSource: selectedVideoSource, - selectedAudioSource: selectedAudioSource, - settings: _ + sources: _, + selectedVideoSource: _, + selectedAudioSource: _, + settings: _, + detailSingleStreamViewModel: viewModel ): - return SingleStreamViewModel( - videoViewModels: sources.map { - VideoRendererViewModel( - streamSource: $0, - isSelectedVideoSource: $0 == selectedVideoSource, - isSelectedAudioSource: $0 == selectedAudioSource, - showSourceLabel: false, - showAudioIndicator: false, - videoQuality: .auto - ) - }, - selectedVideoSource: selectedVideoSource, - streamDetail: streamDetail - ) + return viewModel default: return nil @@ -143,7 +169,8 @@ final class StreamViewModel: ObservableObject { sources: sources, selectedVideoSource: _, selectedAudioSource: _, - settings: settings + settings: settings, + detailSingleStreamViewModel: _ ): guard let matchingSource = sources.first(where: { $0.id == source.id }) else { fatalError("Cannot select source thats not part of the current source list") @@ -161,7 +188,8 @@ final class StreamViewModel: ObservableObject { primaryVideoViewModel: VideoRendererViewModel( streamSource: matchingSource, isSelectedVideoSource: true, - isSelectedAudioSource: matchingSource.id == selectedAudioSource?.id, + isSelectedAudioSource: matchingSource.id == selectedAudioSource?.id, + isPiPView: !isShowingDetailSingleViewScreen, showSourceLabel: showSourceLabels, showAudioIndicator: matchingSource.id == selectedAudioSource?.id, videoQuality: .auto @@ -170,14 +198,16 @@ final class StreamViewModel: ObservableObject { VideoRendererViewModel( streamSource: $0, isSelectedVideoSource: false, - isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isPiPView: false, showSourceLabel: showSourceLabels, showAudioIndicator: $0.id == selectedAudioSource?.id, videoQuality: $0.videoQualityList.contains(.low) ? .low : .auto ) - } + }, + viewRendererProvider: gridViewRendererProvider ) - + updatedDisplayMode = .grid(gridViewModel) case .list: let secondaryVideoSources = secondaryVideoSources(sources, matchingSource) @@ -188,7 +218,8 @@ final class StreamViewModel: ObservableObject { primaryVideoViewModel: VideoRendererViewModel( streamSource: matchingSource, isSelectedVideoSource: true, - isSelectedAudioSource: matchingSource.id == selectedAudioSource?.id, + isSelectedAudioSource: matchingSource.id == selectedAudioSource?.id, + isPiPView: !isShowingDetailSingleViewScreen, showSourceLabel: showSourceLabels, showAudioIndicator: matchingSource.id == selectedAudioSource?.id, videoQuality: primaryVideoQuality @@ -197,14 +228,17 @@ final class StreamViewModel: ObservableObject { VideoRendererViewModel( streamSource: $0, isSelectedVideoSource: false, - isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isPiPView: false, showSourceLabel: showSourceLabels, showAudioIndicator: $0.id == selectedAudioSource?.id, videoQuality: $0.videoQualityList.contains(.low) ? .low : .auto ) - } + }, + mainViewRendererProvider: listViewMainRendererProvider, + thumbnailViewRendererProvider: listViewThumbnailRendererProvider ) - + updatedDisplayMode = .list(listViewModel) case .single: let singleStreamViewModel = SingleStreamViewModel( @@ -212,24 +246,43 @@ final class StreamViewModel: ObservableObject { VideoRendererViewModel( streamSource: $0, isSelectedVideoSource: $0.id == matchingSource.id, - isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isPiPView: !isShowingDetailSingleViewScreen && $0.id == matchingSource.id, showSourceLabel: false, showAudioIndicator: false, videoQuality: .auto ) }, selectedVideoSource: matchingSource, - streamDetail: streamDetail + streamDetail: streamDetail, + viewRendererProvider: singleViewRendererProvider ) updatedDisplayMode = .single(singleStreamViewModel) } + let detailSingleStreamViewModel = SingleStreamViewModel( + videoViewModels: sources.map { + VideoRendererViewModel( + streamSource: $0, + isSelectedVideoSource: $0.id == matchingSource.id, + isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isPiPView: isShowingDetailSingleViewScreen && $0.id == matchingSource.id, + showSourceLabel: false, + showAudioIndicator: false, + videoQuality: .auto + ) + }, + selectedVideoSource: matchingSource, + streamDetail: streamDetail, + viewRendererProvider: singleViewRendererProvider + ) internalState = .success( displayMode: updatedDisplayMode, sources: sources, selectedVideoSource: matchingSource, selectedAudioSource: selectedAudioSource, - settings: settings + settings: settings, + detailSingleStreamViewModel: detailSingleStreamViewModel ) default: fatalError("Cannot select source when the state is not `.success`") @@ -262,7 +315,6 @@ final class StreamViewModel: ObservableObject { guard let self = self else { return } await self.streamOrchestrator.statePublisher .combineLatest(settingsPublisher) - .receive(on: DispatchQueue.main) .sink { state, settings in switch state { case let .subscribed(sources: sources, numberOfStreamViewers: _): @@ -277,10 +329,10 @@ final class StreamViewModel: ObservableObject { self.internalState = .error(.noInternet) } } - .store(in: &self.subscriptions) + .store(in: &subscriptions) } } - + // swiftlint:disable cyclomatic_complexity function_body_length private func updateState(from sources: [StreamSource], settings: StreamSettings) { guard !sources.isEmpty else { @@ -297,7 +349,7 @@ final class StreamViewModel: ObservableObject { } let selectedVideoSource: StreamSource - + switch internalState { case .error, .loading: selectedVideoSource = sortedSources[0] @@ -307,7 +359,8 @@ final class StreamViewModel: ObservableObject { sources: _, selectedVideoSource: currentlySelectedVideoSource, selectedAudioSource: _, - settings: _ + settings: _, + detailSingleStreamViewModel: _ ): selectedVideoSource = sources.first { $0.id == currentlySelectedVideoSource.id } ?? sortedSources[0] } @@ -325,7 +378,8 @@ final class StreamViewModel: ObservableObject { primaryVideoViewModel: VideoRendererViewModel( streamSource: selectedVideoSource, isSelectedVideoSource: true, - isSelectedAudioSource: selectedVideoSource.id == selectedAudioSource?.id, + isSelectedAudioSource: selectedVideoSource.id == selectedAudioSource?.id, + isPiPView: !isShowingDetailSingleViewScreen, showSourceLabel: showSourceLabels, showAudioIndicator: selectedVideoSource.id == selectedAudioSource?.id, videoQuality: primaryVideoQuality @@ -334,14 +388,17 @@ final class StreamViewModel: ObservableObject { VideoRendererViewModel( streamSource: $0, isSelectedVideoSource: false, - isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isPiPView: false, showSourceLabel: showSourceLabels, showAudioIndicator: $0.id == selectedAudioSource?.id, videoQuality: $0.videoQualityList.contains(.low) ? .low : .auto ) - } + }, + mainViewRendererProvider: listViewMainRendererProvider, + thumbnailViewRendererProvider: listViewThumbnailRendererProvider ) - + displayMode = .list(listViewModel) case .grid: let secondaryVideoSources = sortedSources.filter { $0.id != selectedVideoSource.id } @@ -351,7 +408,8 @@ final class StreamViewModel: ObservableObject { primaryVideoViewModel: VideoRendererViewModel( streamSource: selectedVideoSource, isSelectedVideoSource: true, - isSelectedAudioSource: selectedVideoSource.id == selectedAudioSource?.id, + isSelectedAudioSource: selectedVideoSource.id == selectedAudioSource?.id, + isPiPView: !isShowingDetailSingleViewScreen, showSourceLabel: showSourceLabels, showAudioIndicator: selectedVideoSource.id == selectedAudioSource?.id, videoQuality: .auto @@ -360,14 +418,16 @@ final class StreamViewModel: ObservableObject { VideoRendererViewModel( streamSource: $0, isSelectedVideoSource: false, - isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isPiPView: false, showSourceLabel: showSourceLabels, showAudioIndicator: $0.id == selectedAudioSource?.id, videoQuality: $0.videoQualityList.contains(.low) ? .low : .auto ) - } + }, + viewRendererProvider: gridViewRendererProvider ) - + displayMode = .grid(gridViewModel) case .single: let singleStreamViewModel = SingleStreamViewModel( @@ -375,26 +435,46 @@ final class StreamViewModel: ObservableObject { VideoRendererViewModel( streamSource: $0, isSelectedVideoSource: $0.id == selectedVideoSource.id, - isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isPiPView: !isShowingDetailSingleViewScreen && $0.id == selectedVideoSource.id, showSourceLabel: false, showAudioIndicator: false, videoQuality: .auto ) }, selectedVideoSource: selectedVideoSource, - streamDetail: streamDetail + streamDetail: streamDetail, + viewRendererProvider: singleViewRendererProvider ) displayMode = .single(singleStreamViewModel) } + let detailSingleStreamViewModel = SingleStreamViewModel( + videoViewModels: sortedSources.map { + VideoRendererViewModel( + streamSource: $0, + isSelectedVideoSource: $0.id == selectedVideoSource.id, + isSelectedAudioSource: $0.id == selectedAudioSource?.id, + isPiPView: isShowingDetailSingleViewScreen && $0.id == selectedVideoSource.id, + showSourceLabel: false, + showAudioIndicator: false, + videoQuality: .auto + ) + }, + selectedVideoSource: selectedVideoSource, + streamDetail: streamDetail, + viewRendererProvider: singleViewRendererProvider + ) self.internalState = .success( displayMode: displayMode, sources: sortedSources, selectedVideoSource: selectedVideoSource, selectedAudioSource: selectedAudioSource, - settings: settings + settings: settings, + detailSingleStreamViewModel: detailSingleStreamViewModel ) } + // swiftlint:enable cyclomatic_complexity function_body_length private func updateStreamSettings(from sources: [StreamSource], settings: StreamSettings) { // Only update the settings when the sources change, only sources with at least one audio track @@ -434,11 +514,12 @@ final class StreamViewModel: ObservableObject { } return selectedAudioSource } - // swiftlint:enable cyclomatic_complexity function_body_length + + private func stopPiP() { + PiPManager.shared.stopPiP() + } } -// swiftlint:enable type_body_length - fileprivate extension StreamViewModel.InternalState { var selectedAudioSource: StreamSource? { switch self { @@ -447,11 +528,61 @@ fileprivate extension StreamViewModel.InternalState { sources: _, selectedVideoSource: _, selectedAudioSource: currentlySelectedAudioSource, - settings: _ + settings: _, + detailSingleStreamViewModel: _ ): return currentlySelectedAudioSource default: return nil } } + + var selectedVideoSource: StreamSource? { + switch self { + case let .success( + displayMode: _, + sources: _, + selectedVideoSource: currentlySelectedVideoSource, + selectedAudioSource: _, + settings: _, + detailSingleStreamViewModel: _ + ): + return currentlySelectedVideoSource + default: + return nil + } + } + + var displayMode: StreamViewModel.DisplayMode? { + switch self { + case let .success( + displayMode: currentDisplayMode, + sources: _, + selectedVideoSource: _, + selectedAudioSource: _, + settings: _, + detailSingleStreamViewModel: _ + ): + return currentDisplayMode + default: + return nil + } + } + + var isShowingVideoStreams: Bool { + switch self { + case let .success( + displayMode: _, + sources: sources, + selectedVideoSource: _, + selectedAudioSource: _, + settings: _, + detailSingleStreamViewModel: _ + ): + return !sources.isEmpty + default: + return false + } + } } +// swiftlint:enable type_body_length diff --git a/Sources/DolbyIORTSUIKit/Private/Views/GridView/GridView.swift b/Sources/DolbyIORTSUIKit/Private/Views/GridView/GridView.swift index a13c1ef..123b9f8 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/GridView/GridView.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/GridView/GridView.swift @@ -31,7 +31,6 @@ struct GridView: View { private let layout: GridViewLayout private let onVideoSelection: (StreamSource) -> Void @State private var deviceOrientation: UIDeviceOrientation = UIDeviceOrientation.portrait - @StateObject private var viewRendererProvider: ViewRendererProvider = .init() init( viewModel: GridViewModel, @@ -67,20 +66,20 @@ struct GridView: View { let columns = [GridItem](repeating: GridItem(.flexible(), spacing: Layout.spacing1x), count: columnsCount) return ScrollView { LazyVGrid(columns: columns, alignment: .leading) { - ForEach(viewModel.allVideoViewModels, id: \.streamSource.id) { viewModel in + ForEach(viewModel.allVideoViewModels, id: \.streamSource.id) { videoViewModel in let maxAllowedSubVideoWidth = screenSize.width * thumbnailSizeRatio let maxAllowedSubVideoHeight = screenSize.height * thumbnailSizeRatio VideoRendererView( - viewModel: viewModel, - viewRenderer: viewRendererProvider.renderer(for: viewModel.streamSource, isPortait: deviceOrientation.isPortrait), + viewModel: videoViewModel, + viewRenderer: viewModel.viewRendererProvider.renderer(for: videoViewModel.streamSource, isPortait: deviceOrientation.isPortrait), maxWidth: maxAllowedSubVideoWidth, maxHeight: maxAllowedSubVideoHeight, contentMode: .aspectFit ) { source in onVideoSelection(source) } - .id(viewModel.streamSource.id) + .id(videoViewModel.streamSource.id) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) } } @@ -93,17 +92,17 @@ struct GridView: View { return ScrollView(.horizontal) { LazyHGrid(rows: rows, alignment: .top, spacing: Layout.spacing1x) { - ForEach(viewModel.allVideoViewModels, id: \.streamSource.id) { viewModel in + ForEach(viewModel.allVideoViewModels, id: \.streamSource.id) { videoViewModel in VideoRendererView( - viewModel: viewModel, - viewRenderer: viewRendererProvider.renderer(for: viewModel.streamSource, isPortait: deviceOrientation.isPortrait), + viewModel: videoViewModel, + viewRenderer: viewModel.viewRendererProvider.renderer(for: videoViewModel.streamSource, isPortait: deviceOrientation.isPortrait), maxWidth: .infinity, maxHeight: availableHeight / CGFloat(rowsCount), contentMode: .aspectFit ) { source in onVideoSelection(source) } - .id(viewModel.streamSource.id) + .id(videoViewModel.streamSource.id) } }.frame(height: availableHeight) } diff --git a/Sources/DolbyIORTSUIKit/Private/Views/GridView/GridViewModel.swift b/Sources/DolbyIORTSUIKit/Private/Views/GridView/GridViewModel.swift index 5ddf1c5..fb36978 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/GridView/GridViewModel.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/GridView/GridViewModel.swift @@ -8,9 +8,15 @@ import DolbyIORTSCore final class GridViewModel { var allVideoViewModels: [VideoRendererViewModel] + let viewRendererProvider: ViewRendererProvider - init(primaryVideoViewModel: VideoRendererViewModel, secondaryVideoViewModels: [VideoRendererViewModel]) { + init( + primaryVideoViewModel: VideoRendererViewModel, + secondaryVideoViewModels: [VideoRendererViewModel], + viewRendererProvider: ViewRendererProvider + ) { self.allVideoViewModels = [primaryVideoViewModel] self.allVideoViewModels.append(contentsOf: secondaryVideoViewModels) + self.viewRendererProvider = viewRendererProvider } } diff --git a/Sources/DolbyIORTSUIKit/Private/Views/ListView/ListView.swift b/Sources/DolbyIORTSUIKit/Private/Views/ListView/ListView.swift index bea68ff..34766c7 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/ListView/ListView.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/ListView/ListView.swift @@ -38,9 +38,8 @@ struct ListView: View { private let layout: ListViewLayout private let onPrimaryVideoSelection: (StreamSource) -> Void private let onSecondaryVideoSelection: (StreamSource) -> Void + @State private var deviceOrientation: UIDeviceOrientation = UIDeviceOrientation.portrait - @StateObject private var mainViewRendererProvider: ViewRendererProvider = .init() - @StateObject private var thumbnailViewRendererProvider: ViewRendererProvider = .init() init( viewModel: ListViewModel, @@ -207,34 +206,34 @@ struct ListView: View { } private func mainView(_ maxAllowedMainVideoSize: CGSize) -> some View { - let viewModel = viewModel.primaryVideoViewModel + let primaryVideoViewModel = viewModel.primaryVideoViewModel return VideoRendererView( - viewModel: viewModel, - viewRenderer: mainViewRendererProvider.renderer(for: viewModel.streamSource, isPortait: deviceOrientation.isPortrait), + viewModel: primaryVideoViewModel, + viewRenderer: viewModel.mainViewRendererProvider.renderer(for: primaryVideoViewModel.streamSource, isPortait: deviceOrientation.isPortrait), maxWidth: maxAllowedMainVideoSize.width, maxHeight: maxAllowedMainVideoSize.height, contentMode: .aspectFit ) { source in onPrimaryVideoSelection(source) } - .id(viewModel.streamSource.id) + .id(primaryVideoViewModel.streamSource.id) } private func gridVertical(_ screenSize: CGSize, _ thumbnailSizeRatio: CGFloat) -> some View { - return ForEach(viewModel.secondaryVideoViewModels, id: \.streamSource.id) { viewModel in + return ForEach(viewModel.secondaryVideoViewModels, id: \.streamSource.id) { secondaryVideoViewModel in let maxAllowedSubVideoWidth = screenSize.width * thumbnailSizeRatio let maxAllowedSubVideoHeight = screenSize.height * thumbnailSizeRatio VideoRendererView( - viewModel: viewModel, - viewRenderer: thumbnailViewRendererProvider.renderer(for: viewModel.streamSource, isPortait: deviceOrientation.isPortrait), + viewModel: secondaryVideoViewModel, + viewRenderer: viewModel.thumbnailViewRendererProvider.renderer(for: secondaryVideoViewModel.streamSource, isPortait: deviceOrientation.isPortrait), maxWidth: maxAllowedSubVideoWidth, maxHeight: maxAllowedSubVideoHeight, contentMode: .aspectFit ) { source in onSecondaryVideoSelection(source) } - .id(viewModel.streamSource.id) + .id(secondaryVideoViewModel.streamSource.id) } } @@ -254,17 +253,17 @@ struct ListView: View { let rows = [GridItem](repeating: GridItem(.fixed(CGFloat(availableHeight)), spacing: Layout.spacing1x), count: rowsCount) return LazyHGrid(rows: rows, alignment: .top, spacing: Layout.spacing1x) { - ForEach(viewModel.secondaryVideoViewModels, id: \.streamSource.id) { viewModel in + ForEach(viewModel.secondaryVideoViewModels, id: \.streamSource.id) { secondaryVideoViewModel in VideoRendererView( - viewModel: viewModel, - viewRenderer: thumbnailViewRendererProvider.renderer(for: viewModel.streamSource, isPortait: deviceOrientation.isPortrait), + viewModel: secondaryVideoViewModel, + viewRenderer: viewModel.thumbnailViewRendererProvider.renderer(for: secondaryVideoViewModel.streamSource, isPortait: deviceOrientation.isPortrait), maxWidth: .infinity, maxHeight: availableHeight, contentMode: .aspectFit ) { source in onSecondaryVideoSelection(source) } - .id(viewModel.streamSource.id) + .id(secondaryVideoViewModel.streamSource.id) } } } diff --git a/Sources/DolbyIORTSUIKit/Private/Views/ListView/ListViewModel.swift b/Sources/DolbyIORTSUIKit/Private/Views/ListView/ListViewModel.swift index 966ea39..028f3d2 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/ListView/ListViewModel.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/ListView/ListViewModel.swift @@ -9,9 +9,18 @@ final class ListViewModel { let primaryVideoViewModel: VideoRendererViewModel let secondaryVideoViewModels: [VideoRendererViewModel] + let mainViewRendererProvider: ViewRendererProvider + let thumbnailViewRendererProvider: ViewRendererProvider - init(primaryVideoViewModel: VideoRendererViewModel, secondaryVideoViewModels: [VideoRendererViewModel]) { + init( + primaryVideoViewModel: VideoRendererViewModel, + secondaryVideoViewModels: [VideoRendererViewModel], + mainViewRendererProvider: ViewRendererProvider, + thumbnailViewRendererProvider: ViewRendererProvider + ) { self.primaryVideoViewModel = primaryVideoViewModel self.secondaryVideoViewModels = secondaryVideoViewModels + self.mainViewRendererProvider = mainViewRendererProvider + self.thumbnailViewRendererProvider = thumbnailViewRendererProvider } } diff --git a/Sources/DolbyIORTSUIKit/Private/Views/SingleStream/SingleStreamView.swift b/Sources/DolbyIORTSUIKit/Private/Views/SingleStream/SingleStreamView.swift index 55c3af0..b467b20 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/SingleStream/SingleStreamView.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/SingleStream/SingleStreamView.swift @@ -26,7 +26,6 @@ struct SingleStreamView: View { @State private var deviceOrientation: UIDeviceOrientation = UIDeviceOrientation.portrait @StateObject private var userInteractionViewModel: UserInteractionViewModel = .init() - @StateObject private var viewRendererProvider: ViewRendererProvider = .init() @ObservedObject private var themeManager = ThemeManager.shared @@ -102,7 +101,7 @@ struct SingleStreamView: View { let maxAllowedVideoHeight = proxy.size.height VideoRendererView( viewModel: videoRendererViewModel, - viewRenderer: viewRendererProvider.renderer(for: videoRendererViewModel.streamSource, isPortait: deviceOrientation.isPortrait), + viewRenderer: viewModel.viewRendererProvider.renderer(for: videoRendererViewModel.streamSource, isPortait: deviceOrientation.isPortrait), maxWidth: maxAllowedVideoWidth, maxHeight: maxAllowedVideoHeight, contentMode: .aspectFit diff --git a/Sources/DolbyIORTSUIKit/Private/Views/SingleStream/SingleStreamViewModel.swift b/Sources/DolbyIORTSUIKit/Private/Views/SingleStream/SingleStreamViewModel.swift index e7436ba..4f80b08 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/SingleStream/SingleStreamViewModel.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/SingleStream/SingleStreamViewModel.swift @@ -11,15 +11,18 @@ final class SingleStreamViewModel { let videoViewModels: [VideoRendererViewModel] let selectedVideoSource: StreamSource let settingsMode: SettingsMode + let viewRendererProvider: ViewRendererProvider init( videoViewModels: [VideoRendererViewModel], selectedVideoSource: StreamSource, - streamDetail: StreamDetail + streamDetail: StreamDetail, + viewRendererProvider: ViewRendererProvider ) { self.videoViewModels = videoViewModels self.selectedVideoSource = selectedVideoSource self.settingsMode = .stream(streamName: streamDetail.streamName, accountID: streamDetail.accountID) + self.viewRendererProvider = viewRendererProvider } func streamSource(for id: UUID) -> StreamSource? { diff --git a/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererView.swift b/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererView.swift index 5d8b725..462ef7d 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererView.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererView.swift @@ -4,10 +4,11 @@ import DolbyIORTSCore import DolbyIOUIKit +import MillicastSDK import SwiftUI struct VideoRendererView: View { - @ObservedObject private var viewModel: VideoRendererViewModel + private let viewModel: VideoRendererViewModel private let viewRenderer: StreamSourceViewRenderer private let maxWidth: CGFloat private let maxHeight: CGFloat @@ -98,7 +99,7 @@ struct VideoRendererView: View { } }() - VideoRendererViewInteral(viewRenderer: viewRenderer) + VideoRendererViewInternal(viewModel: viewModel, viewRenderer: viewRenderer) .frame(width: videoSize.width, height: videoSize.height) .overlay(alignment: .bottomLeading) { sourceLabelView @@ -127,54 +128,74 @@ struct VideoRendererView: View { } } -private struct VideoRendererViewInteral: UIViewRepresentable { +private struct VideoRendererViewInternal: UIViewControllerRepresentable { + private let viewModel: VideoRendererViewModel private let viewRenderer: StreamSourceViewRenderer - init(viewRenderer: StreamSourceViewRenderer) { + init(viewModel: VideoRendererViewModel, viewRenderer: StreamSourceViewRenderer) { + self.viewModel = viewModel self.viewRenderer = viewRenderer } - func makeUIView(context: Context) -> UIView { - let containerView = ContainerView() - containerView.updateChildView(viewRenderer.playbackView) - return containerView + func makeUIViewController(context: Context) -> UIViewController { + WrappedViewController(viewModel: viewModel, viewRenderer: viewRenderer) } - func updateUIView(_ uiView: UIView, context: Context) { - guard let containerView = uiView as? ContainerView else { - return - } - containerView.updateChildView(viewRenderer.playbackView) + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + guard let wrappedView = uiViewController as? WrappedViewController else { return } + + wrappedView.updateViewModel(viewModel) } } -private final class ContainerView: UIView { - - private var childView: ChildView? +private class WrappedViewController: UIViewController { + private var viewModel: VideoRendererViewModel + private let viewRenderer: StreamSourceViewRenderer + + @AppConfiguration(\.enablePiP) private var enablePiP - init() { - super.init(frame: CGRect(x: 0, y: 0, width: .zero, height: .zero)) + init(viewModel: VideoRendererViewModel, viewRenderer: StreamSourceViewRenderer) { + self.viewModel = viewModel + self.viewRenderer = viewRenderer + super.init(nibName: nil, bundle: nil) + setupView() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func updateChildView(_ view: ChildView) { - childView?.removeFromSuperview() - - view.translatesAutoresizingMaskIntoConstraints = false - - addSubview(view) + + private func setupView() { + let playbackView = viewRenderer.playbackView + self.view.addSubview(viewRenderer.playbackView) + playbackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ - topAnchor.constraint(equalTo: view.topAnchor), - leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.bottomAnchor.constraint(equalTo: bottomAnchor), - view.trailingAnchor.constraint(equalTo: trailingAnchor) + self.view.topAnchor.constraint(equalTo: playbackView.topAnchor), + self.view.leadingAnchor.constraint(equalTo: playbackView.leadingAnchor), + playbackView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + playbackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) ]) - childView = view - - setNeedsLayout() - layoutIfNeeded() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + configurePiPIfRequired() + } + + func updateViewModel(_ viewModel: VideoRendererViewModel) { + self.viewModel = viewModel + configurePiPIfRequired() + } + + private func configurePiPIfRequired() { + guard + viewModel.isPiPView, + enablePiP, + viewRenderer.playbackView.frame != .zero, + PiPManager.shared.pipView != viewRenderer.pipView + else { return } + + PiPManager.shared.set(pipView: viewRenderer.pipView, with: viewRenderer.playbackView) } } diff --git a/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererViewModel.swift b/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererViewModel.swift index 142e32c..75e8643 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererViewModel.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererViewModel.swift @@ -14,6 +14,7 @@ final class VideoRendererViewModel: ObservableObject { private let streamOrchestrator: StreamOrchestrator let isSelectedVideoSource: Bool let isSelectedAudioSource: Bool + let isPiPView: Bool let streamSource: StreamSource let showSourceLabel: Bool let showAudioIndicator: Bool @@ -23,6 +24,7 @@ final class VideoRendererViewModel: ObservableObject { streamSource: StreamSource, isSelectedVideoSource: Bool, isSelectedAudioSource: Bool, + isPiPView: Bool, showSourceLabel: Bool, showAudioIndicator: Bool, videoQuality: VideoQuality, @@ -31,6 +33,7 @@ final class VideoRendererViewModel: ObservableObject { self.streamSource = streamSource self.isSelectedVideoSource = isSelectedVideoSource self.isSelectedAudioSource = isSelectedAudioSource + self.isPiPView = isPiPView self.showSourceLabel = showSourceLabel self.showAudioIndicator = showAudioIndicator self.videoQuality = videoQuality diff --git a/Sources/DolbyIORTSUIKit/Public/Configurations/AppConfigurations.swift b/Sources/DolbyIORTSUIKit/Public/Configurations/AppConfigurations.swift index 5822df6..6c5d9b2 100644 --- a/Sources/DolbyIORTSUIKit/Public/Configurations/AppConfigurations.swift +++ b/Sources/DolbyIORTSUIKit/Public/Configurations/AppConfigurations.swift @@ -22,6 +22,9 @@ public final class AppConfigurations { @UserDefault("show_debug_features") public var showDebugFeatures: Bool = false + + @UserDefault("enable_pip") + public var enablePiP: Bool = false } @propertyWrapper diff --git a/Sources/DolbyIORTSUIKit/Public/Screens/Media/StreamingScreen.swift b/Sources/DolbyIORTSUIKit/Public/Screens/Media/StreamingScreen.swift index 7691cfc..72f2fe7 100644 --- a/Sources/DolbyIORTSUIKit/Public/Screens/Media/StreamingScreen.swift +++ b/Sources/DolbyIORTSUIKit/Public/Screens/Media/StreamingScreen.swift @@ -20,7 +20,6 @@ public struct StreamingScreen: View { } @StateObject private var viewModel: StreamViewModel - @State private var isShowingSingleViewScreen: Bool = false @State private var isShowingSettingsScreen: Bool = false @ObservedObject private var themeManager = ThemeManager.shared @@ -47,12 +46,12 @@ public struct StreamingScreen: View { if let singleStreamUiState = viewModel.detailSingleStreamViewModel { SingleStreamView( viewModel: singleStreamUiState, - isShowingDetailPresentation: true, + isShowingDetailPresentation: true, onSelect: { viewModel.selectVideoSource($0) }, onClose: { - isShowingSingleViewScreen = false + viewModel.isShowingDetailSingleViewScreen = false } ) } else { @@ -68,7 +67,7 @@ public struct StreamingScreen: View { ListView( viewModel: listViewModel, onPrimaryVideoSelection: { _ in - isShowingSingleViewScreen = true + viewModel.isShowingDetailSingleViewScreen = true }, onSecondaryVideoSelection: { viewModel.selectVideoSource($0) @@ -77,7 +76,7 @@ public struct StreamingScreen: View { case let .single(SingleStreamViewModel): SingleStreamView( viewModel: SingleStreamViewModel, - isShowingDetailPresentation: false, + isShowingDetailPresentation: false, onSelect: { viewModel.selectVideoSource($0) } @@ -87,7 +86,7 @@ public struct StreamingScreen: View { viewModel: gridViewModel, onVideoSelection: { viewModel.selectVideoSource($0) - isShowingSingleViewScreen = true + viewModel.isShowingDetailSingleViewScreen = true } ) } @@ -185,7 +184,7 @@ public struct StreamingScreen: View { destination: LazyNavigationDestinationView( singleStreamDetailView ), - isActive: $isShowingSingleViewScreen + isActive: $viewModel.isShowingDetailSingleViewScreen ) { EmptyView() }