diff --git a/Package.resolved b/Package.resolved index 553dcd2..3af4f76 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/millicast/millicast-sdk-swift-package", "state" : { - "revision" : "f5e0abd63564f591d4fef0ab82d9e0cdee858579", - "version" : "1.5.0" + "revision" : "001f8654ba31461ecf805ae8a15e4d92efa8064e", + "version" : "1.7.0" } } ], diff --git a/Package.swift b/Package.swift index 7f26916..e5233c2 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( targets: ["DolbyIORTSUIKit"]) ], dependencies: [ - .package(url: "https://github.com/millicast/millicast-sdk-swift-package", from: "1.5.1") + .package(url: "https://github.com/millicast/millicast-sdk-swift-package", exact: "1.7.0") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/Sources/DolbyIORTSCore/Manager/SubscriptionManager.swift b/Sources/DolbyIORTSCore/Manager/SubscriptionManager.swift index 0117509..14ae5e0 100644 --- a/Sources/DolbyIORTSCore/Manager/SubscriptionManager.swift +++ b/Sources/DolbyIORTSCore/Manager/SubscriptionManager.swift @@ -22,7 +22,7 @@ protocol SubscriptionManagerDelegate: AnyObject { func onStopped() - func onLayers(_ mid: String, activeLayers: [MCLayerData], inactiveLayers: [MCLayerData]) + func onLayers(_ mid: String, activeLayers: [MCLayerData], inactiveLayers: [String]) func onConnected() @@ -41,7 +41,7 @@ protocol SubscriptionManagerProtocol: AnyObject { var delegate: SubscriptionManagerDelegate? { get set } func connect(streamName: String, accountID: String, configuration: SubscriptionConfiguration) async -> Bool - func startSubscribe() async -> Bool + func startSubscribe(configuration: SubscriptionConfiguration) async -> Bool func stopSubscribe() async -> Bool func addRemoteTrack(_ sourceBuilder: StreamSourceBuilder) func projectVideo(for source: StreamSource, withQuality quality: VideoQuality) @@ -93,7 +93,10 @@ final class SubscriptionManager: SubscriptionManagerProtocol { self.subscriber.setCredentials(credentials) - guard self.subscriber.connect() else { + let connectionOptions = MCConnectionOptions() + connectionOptions.autoReconnect = configuration.autoReconnect + + guard self.subscriber.connect(with: connectionOptions) else { Self.logger.error("💼 Subscriber has failed to connect") return false } @@ -104,7 +107,7 @@ final class SubscriptionManager: SubscriptionManagerProtocol { return await task.value } - func startSubscribe() async -> Bool { + func startSubscribe(configuration: SubscriptionConfiguration) async -> Bool { let task = Task { [weak self] () -> Bool in Self.logger.debug("💼 Start subscribe") @@ -122,7 +125,16 @@ final class SubscriptionManager: SubscriptionManagerProtocol { return false } - guard self.subscriber.subscribe() else { + let options = MCClientOptions() + options.videoJitterMinimumDelayMs = Int32(configuration.videoJitterMinimumDelayInMs) + options.statsDelayMs = Int32(configuration.statsDelayMs) + if let rtcEventLogOutputPath = configuration.rtcEventLogPath { + options.rtcEventLogOutputPath = rtcEventLogOutputPath + } + options.disableAudio = configuration.disableAudio + options.forcePlayoutDelay = configuration.noPlayoutDelay + + guard self.subscriber.subscribe(with: options) else { Self.logger.error("💼 Subscribe call has failed") return false } @@ -219,18 +231,6 @@ private extension SubscriptionManager { func makeSubscriber(with configuration: SubscriptionConfiguration) -> MCSubscriber? { let subscriber = MCSubscriber.create() - - let options = MCClientOptions() - options.autoReconnect = configuration.autoReconnect - options.videoJitterMinimumDelayMs = Int32(configuration.videoJitterMinimumDelayInMs) - options.statsDelayMs = Int32(configuration.statsDelayMs) - if let rtcEventLogOutputPath = configuration.rtcEventLogPath { - options.rtcEventLogOutputPath = rtcEventLogOutputPath - } - options.disableAudio = configuration.disableAudio - options.forcePlayoutDelay = configuration.noPlayoutDelay - - subscriber?.setOptions(options) subscriber?.enableStats(configuration.enableStats) return subscriber @@ -294,7 +294,7 @@ extension SubscriptionManager: MCSubscriberListener { Self.logger.debug("💼 Delegate - onVad with mid \(mid), sourceId \(sourceId)") } - func onLayers(_ mid: String, activeLayers: [MCLayerData], inactiveLayers: [MCLayerData]) { + func onLayers(_ mid: String, activeLayers: [MCLayerData], inactiveLayers: [String]) { Self.logger.debug("💼 Delegate - onLayers for mid - \(mid) with activeLayers \(activeLayers), inactiveLayers \(inactiveLayers)") delegate?.onLayers(mid, activeLayers: activeLayers, inactiveLayers: inactiveLayers) } 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/DolbyIORTSCore/State/StateMachine.swift b/Sources/DolbyIORTSCore/State/StateMachine.swift index d4d2976..deee425 100644 --- a/Sources/DolbyIORTSCore/State/StateMachine.swift +++ b/Sources/DolbyIORTSCore/State/StateMachine.swift @@ -146,28 +146,50 @@ final class StateMachine { } } - func onLayers(_ mid: String, activeLayers: [MCLayerData], inactiveLayers: [MCLayerData]) { + func onLayers(_ mid: String, activeLayers: [MCLayerData], inactiveLayers: [String]) { switch currentState { case let .subscribed(state): let streamTypes: [StreamSource.LowLevelVideoQuality] - let filteredActiveLayers = activeLayers.filter({ layer in - // For H.264 there are no temporal layers and the id is set to 255. For VP8 use the first temporal layer. - return layer.temporalLayerId == 0 || layer.temporalLayerId == 255 - }) + var layersForSelection: [MCLayerData] = [] + + // Simulcast active layers + let simulcastLayers = activeLayers.filter { !$0.encodingId.isEmpty } + if !simulcastLayers.isEmpty { + // Select the max (best) temporal layer Id from a specific encodingId + let dictionaryOfLayersMatchingEncodingId = Dictionary(grouping: simulcastLayers, by: { $0.encodingId }) + dictionaryOfLayersMatchingEncodingId.forEach { (encodingId: String, layers: [MCLayerData]) in + // Picking the layer matching the max temporal layer id - represents the layer with the best FPS + if let layerWithBestFrameRate = layers.first { $0.temporalLayerId == $0.maxTemporalLayerId } ?? layers.last { + layersForSelection.append(layerWithBestFrameRate) + } + } + layersForSelection.sort(by: >) + } + // Using SVC layer selection logic + else { + let simulcastLayers = activeLayers.filter { $0.spatialLayerId != nil } + let dictionaryOfLayersMatchingSpatialLayerId = Dictionary(grouping: simulcastLayers, by: { $0.spatialLayerId! }) + dictionaryOfLayersMatchingSpatialLayerId.forEach { (spatialLayerId: NSNumber, layers: [MCLayerData]) in + // Picking the layer matching the max temporal layer id - represents the layer with the best FPS + if let layerWithBestFrameRate = layers.first { $0.spatialLayerId == $0.maxSpatialLayerId } ?? layers.last { + layersForSelection.append(layerWithBestFrameRate) + } + } + } - switch filteredActiveLayers.count { + switch layersForSelection.count { case 2: streamTypes = [ .auto, - .high(layer: filteredActiveLayers[0]), - .low(layer: filteredActiveLayers[1]) + .high(layer: layersForSelection[0]), + .low(layer: layersForSelection[1]) ] case 3: streamTypes = [ .auto, - .high(layer: filteredActiveLayers[0]), - .medium(layer: filteredActiveLayers[1]), - .low(layer: filteredActiveLayers[2]) + .high(layer: layersForSelection[0]), + .medium(layer: layersForSelection[1]), + .low(layer: layersForSelection[2]) ] default: streamTypes = [.auto] @@ -214,3 +236,14 @@ final class StateMachine { currentState = .stopped } } + +extension MCLayerData: Comparable { + public static func < (lhs: MCLayerData, rhs: MCLayerData) -> Bool { + switch (lhs.encodingId.lowercased(), rhs.encodingId.lowercased()) { + case ("h", "m"), ("l", "m"), ("h", "s"), ("l", "s"), ("m", "s"): + return false + default: + return true + } + } +} diff --git a/Sources/DolbyIORTSCore/StreamOrchestrator.swift b/Sources/DolbyIORTSCore/StreamOrchestrator.swift index 966211e..f92d170 100644 --- a/Sources/DolbyIORTSCore/StreamOrchestrator.swift +++ b/Sources/DolbyIORTSCore/StreamOrchestrator.swift @@ -29,6 +29,8 @@ public final actor StreamOrchestrator { .eraseToAnyPublisher() private var activeStreamDetail: StreamDetail? private let logHandler: MillicastLoggerHandler = .init() + + private var subscriptionConfiguration: SubscriptionConfiguration = .init() private init() { self.init( @@ -57,6 +59,7 @@ public final actor StreamOrchestrator { public func connect(streamName: String, accountID: String, configuration: SubscriptionConfiguration = .init()) async -> Bool { Self.logger.debug("👮‍♂️ Start subscribe") logHandler.setLogFilePath(filePath: configuration.sdkLogPath) + self.subscriptionConfiguration = configuration async let startConnectionStateUpdate: Void = stateMachine.startConnection( streamName: streamName, @@ -220,7 +223,7 @@ private extension StreamOrchestrator { func startSubscribe() async -> Bool { stateMachine.startSubscribe() - return await subscriptionManager.startSubscribe() + return await subscriptionManager.startSubscribe(configuration: subscriptionConfiguration) } func stopAudio(for sourceId: String?) { @@ -236,6 +239,7 @@ private extension StreamOrchestrator { func reset() { activeStreamDetail = nil logHandler.setLogFilePath(filePath: nil) + subscriptionConfiguration = .init() } } @@ -324,10 +328,10 @@ extension StreamOrchestrator: SubscriptionManagerDelegate { } } - nonisolated func onLayers(_ mid_: String, activeLayers: [MCLayerData], inactiveLayers: [MCLayerData]) { + nonisolated func onLayers(_ mid: String, activeLayers: [MCLayerData], inactiveLayers: [String]) { Task { @StreamOrchestrator [weak self] in guard let self = self else { return } - self.stateMachine.onLayers(mid_, activeLayers: activeLayers, inactiveLayers: inactiveLayers) + self.stateMachine.onLayers(mid, activeLayers: activeLayers, inactiveLayers: inactiveLayers) } } diff --git a/Sources/DolbyIORTSUIKit/Private/Managers/PiPManager.swift b/Sources/DolbyIORTSUIKit/Private/Managers/PiPManager.swift new file mode 100644 index 0000000..1516854 --- /dev/null +++ b/Sources/DolbyIORTSUIKit/Private/Managers/PiPManager.swift @@ -0,0 +1,74 @@ +// +// 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? + + var isPiPActive: Bool { + pipController?.isPictureInPictureActive ?? false + } + + 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..0d0d046 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`") @@ -277,10 +330,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 +350,7 @@ final class StreamViewModel: ObservableObject { } let selectedVideoSource: StreamSource - + switch internalState { case .error, .loading: selectedVideoSource = sortedSources[0] @@ -307,7 +360,8 @@ final class StreamViewModel: ObservableObject { sources: _, selectedVideoSource: currentlySelectedVideoSource, selectedAudioSource: _, - settings: _ + settings: _, + detailSingleStreamViewModel: _ ): selectedVideoSource = sources.first { $0.id == currentlySelectedVideoSource.id } ?? sortedSources[0] } @@ -325,7 +379,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 +389,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 +409,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 +419,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 +436,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 +515,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 +529,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..08eca9e 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,80 @@ 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 + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configurePiPIfRequired(force: true) + } - setNeedsLayout() - layoutIfNeeded() + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + configurePiPIfRequired(force: false) + } + + func updateViewModel(_ viewModel: VideoRendererViewModel) { + self.viewModel = viewModel + configurePiPIfRequired(force: false) + } + + private func configurePiPIfRequired(force: Bool) { + guard + viewModel.isPiPView, + enablePiP, + PiPManager.shared.isPiPActive == false, + viewRenderer.playbackView.frame != .zero, + (PiPManager.shared.pipView != viewRenderer.pipView || force) + 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() }