diff --git a/.DS_Store b/.DS_Store index 63cd999d..f36cfb5f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/interactive-player/Interactive Viewer/ChannelView/ChannelView.swift b/interactive-player/Interactive Viewer/ChannelView/ChannelView.swift new file mode 100644 index 00000000..72428333 --- /dev/null +++ b/interactive-player/Interactive Viewer/ChannelView/ChannelView.swift @@ -0,0 +1,90 @@ +// +// ChannelView.swift +// + +import DolbyIOUIKit +import RTSCore +import SwiftUI + +struct ChannelView: View { + @ObservedObject private var viewModel: ChannelViewModel + @ObservedObject private var themeManager = ThemeManager.shared + + private let onClose: () -> Void + private var theme: Theme { themeManager.theme } + + init(viewModel: ChannelViewModel, onClose: @escaping () -> Void) { + self.viewModel = viewModel + self.onClose = onClose + } + + var body: some View { + NavigationView { + ZStack { + switch viewModel.state { + case let .success(channels: channels): + let viewModel = ChannelGridViewModel(channels: channels) + ChannelGridView(viewModel: viewModel) + .toolbar { + closeToolbarItem + } + case .loading: + progressView + case let .error(title: title, subtitle: subtitle, showLiveIndicator: showLiveIndicator): + errorView(title: title, subtitle: subtitle, showLiveIndicator: showLiveIndicator) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + } + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + viewModel.viewStreams() + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } + } + + @ViewBuilder + private func errorView(title: String, subtitle: String?, showLiveIndicator: Bool) -> some View { + ErrorView(title: title, subtitle: subtitle) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topTrailing) { + closeButton + } + } + + @ViewBuilder + private var closeButton: some View { + IconButton(iconAsset: .close) { + onClose() + viewModel.endStream() + } + .background(Color(uiColor: theme.neutral400)) + .clipShape(Circle().inset(by: Layout.spacing0_5x)) + .accessibilityIdentifier("\(StreamingView.self).CloseButton") + } + + @ViewBuilder + private var progressView: some View { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .toolbar { + closeToolbarItem + } + } + + private var closeToolbarItem: ToolbarItem { + ToolbarItem(placement: .navigationBarLeading) { + IconButton(iconAsset: .close) { + onClose() + viewModel.endStream() + } + } + } +} + +#Preview { + ChannelView(viewModel: ChannelViewModel(channels: .constant([])), onClose: {}) +} diff --git a/interactive-player/Interactive Viewer/ChannelView/ChannelViewModel.swift b/interactive-player/Interactive Viewer/ChannelView/ChannelViewModel.swift new file mode 100644 index 00000000..76846a1b --- /dev/null +++ b/interactive-player/Interactive Viewer/ChannelView/ChannelViewModel.swift @@ -0,0 +1,186 @@ +// +// ChannelViewModel.swift +// + +import Combine +import Foundation +import MillicastSDK +import os +import RTSCore +import SwiftUI + +@MainActor +final class ChannelViewModel: ObservableObject { + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: StreamViewModel.self) + ) + + @Published private(set) var state: State = .loading + + let serialTasks = SerialTasks() + + private let settingsManager: SettingsManager + private var subscriptions: [AnyCancellable] = [] + private var reconnectionTimer: Timer? + private var isWebsocketConnected: Bool = false + private let settingsMode: SettingsMode = .global + @Binding var channels: [Channel]? + private var sourcedChannels: [SourcedChannel] = [] + + enum State { + case loading + case success(channels: [SourcedChannel]) + case error(title: String, subtitle: String?, showLiveIndicator: Bool) + } + + init( + channels: Binding<[Channel]?>, + settingsManager: SettingsManager = .shared + ) { + self._channels = channels + self.settingsManager = settingsManager + startObservers() + } + + @objc func viewStreams() { + guard let channels else { return } + for channel in channels { + viewStream(with: channel) + } + } + + func viewStream(with channel: Channel) { + Task(priority: .userInitiated) { + let subscriptionManager = channel.subscriptionManager + _ = try await subscriptionManager.subscribe( + streamName: channel.streamDetail.streamName, + accountID: channel.streamDetail.accountID, + configuration: channel.configuration + ) + } + } + + func endStream() { + Task(priority: .userInitiated) { [weak self] in + guard let self, + let channels else { return } + for channel in channels { + self.subscriptions.removeAll() + self.reconnectionTimer?.invalidate() + self.reconnectionTimer = nil + await channel.videoTracksManager.reset() + _ = try await channel.subscriptionManager.unSubscribe() + } + } + } + + func scheduleReconnection() { + Self.logger.debug("🎰 Schedule reconnection") + reconnectionTimer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(viewStreams), userInfo: nil, repeats: false) + } + + private func update(state: State) { + self.state = state + } + + private func stopPiP() { + PiPManager.shared.stopPiP() + } +} + +private extension ChannelViewModel { + // swiftlint:disable function_body_length cyclomatic_complexity + func startObservers() { + Task { [weak self] in + guard let self, + let channels else { return } + + for channel in channels { + let settingsPublisher = self.settingsManager.publisher(for: settingsMode) + let statePublisher = await channel.subscriptionManager.$state + + Publishers.CombineLatest(statePublisher, settingsPublisher) + .sink { state, _ in + Self.logger.debug("🎰 State and settings events") + Task { + try await self.serialTasks.enqueue { + switch state { + case let .subscribed(sources: sources): + let activeSources = Array(sources.filter { $0.videoTrack.isActive == true }) + + await self.updateChannelWithSources(channel: channel, sources: activeSources) + + // Register Video Track events + await withTaskGroup(of: Void.self) { group in + for source in activeSources { + group.addTask { + await channel.videoTracksManager.observeLayerUpdates(for: source) + } + } + } + guard !Task.isCancelled else { return } + + case .disconnected: + Self.logger.debug("🎰 Stream disconnected") + await self.update(state: .loading) + + case let .error(connectionError) where connectionError.status == 0: + // Status code `0` represents a `no network error` + Self.logger.debug("🎰 No internet connection") + if await !self.isWebsocketConnected { + await self.scheduleReconnection() + } + await self.update( + state: .error( + title: .noInternetErrorTitle, + subtitle: nil, + showLiveIndicator: false + ) + ) + + case let .error(connectionError): + Self.logger.debug("🎰 Connection error - \(connectionError.status), \(connectionError.reason)") + + if await !self.isWebsocketConnected { + await self.scheduleReconnection() + } + // TODO: This will stop streaming all screens, needs rethinking +// await self.update( +// state: .error( +// title: .offlineErrorTitle, +// subtitle: .offlineErrorSubtitle, +// showLiveIndicator: true +// ) +// ) + } + } + } + } + .store(in: &subscriptions) + + await channel.subscriptionManager.$websocketState + .sink { websocketState in + switch websocketState { + case .connected: + self.isWebsocketConnected = true + default: + break + } + } + .store(in: &subscriptions) + } + } + } + + // swiftlint:enable function_body_length cyclomatic_complexity + + func updateChannelWithSources(channel: Channel, sources: [StreamSource]) { + guard !sourcedChannels.contains(where: { $0.id == channel.id }), + sources.count > 0 else { return } + let sourcedChannel = SourcedChannel.build(from: channel, source: sources[0]) + sourcedChannels.append(sourcedChannel) + + update(state: .success(channels: sourcedChannels)) + } +} diff --git a/interactive-player/Interactive Viewer/Localizable.strings b/interactive-player/Interactive Viewer/Localizable.strings index 56f1c710..c8eab0e0 100644 --- a/interactive-player/Interactive Viewer/Localizable.strings +++ b/interactive-player/Interactive Viewer/Localizable.strings @@ -9,6 +9,7 @@ "stream-detail-accountid-placeholder-label" = "Account ID"; "stream-detail-input.title.label" = "Interactive player"; "stream-detail-input.start-a-stream.label" = "Start a stream"; + "stream-detail-input.subtitle.label" = "Enter the stream name and publisher account ID to view a stream."; "stream-detail-input.play.button" = "Play"; "stream-detail-input.footnote.label" = "Copyright © 2023 Dolby.io — All Rights Reserved"; @@ -37,11 +38,21 @@ "stream-detail-input.max-bitrate-label" = "Maxium Bitrate (kbps):"; "stream-detail-server-url-label" = "Server URL"; +/** Channel Detail Input View */ +"channel-detail-input.start-a-channel.label" = "Multi-Channels"; +"channel-detail-input.subtitle.label" = "Enter up to 4 separate streamNames & accountIDs to view a multiple channels."; +"channel-detail-input.channel-1.label" = "Channel 1"; +"channel-detail-input.channel-2.label" = "Channel 2"; +"channel-detail-input.channel-3.label" = "Channel 3"; +"channel-detail-input.channel-4.label" = "Channel 4"; + + /** App configuration Screen */ "app-configuration-title" = "App configurations"; "app-configuration-show-debug-features-label" = "Show debug features"; "app-configuration-enable-pip-features-label" = "Enable Picture In Picture"; +"app-configuration-enable-multichannel-label" = "Enable MultiChannel"; /** Recent Streams Screen */ diff --git a/interactive-player/Interactive Viewer/Models/Channel.swift b/interactive-player/Interactive Viewer/Models/Channel.swift new file mode 100644 index 00000000..2fe05092 --- /dev/null +++ b/interactive-player/Interactive Viewer/Models/Channel.swift @@ -0,0 +1,38 @@ +// +// Channel.swift +// Interactive Player +// + +import Foundation +import RTSCore + +struct Channel: Identifiable { + let id = UUID() + let streamDetail: StreamDetail + let listViewPrimaryVideoQuality: VideoQuality + let configuration: SubscriptionConfiguration + let subscriptionManager: SubscriptionManager + let videoTracksManager: VideoTracksManager +} + +struct SourcedChannel: Identifiable { + let id: UUID + let streamDetail: StreamDetail + let listViewPrimaryVideoQuality: VideoQuality + let configuration: SubscriptionConfiguration + let subscriptionManager: SubscriptionManager + let videoTracksManager: VideoTracksManager + let source: StreamSource +} + +extension SourcedChannel { + static func build(from channel: Channel, source: StreamSource) -> SourcedChannel { + return SourcedChannel(id: channel.id, + streamDetail: channel.streamDetail, + listViewPrimaryVideoQuality: channel.listViewPrimaryVideoQuality, + configuration: channel.configuration, + subscriptionManager: channel.subscriptionManager, + videoTracksManager: channel.videoTracksManager, + source: source) + } +} diff --git a/interactive-player/Interactive Viewer/Utils/AppConfigurations.swift b/interactive-player/Interactive Viewer/Utils/AppConfigurations.swift index 0af945c0..1784f725 100644 --- a/interactive-player/Interactive Viewer/Utils/AppConfigurations.swift +++ b/interactive-player/Interactive Viewer/Utils/AppConfigurations.swift @@ -7,14 +7,11 @@ import Foundation import SwiftUI final class AppConfigurations { - static let standard = AppConfigurations(userDefaults: .standard) fileprivate let userDefaults: UserDefaults fileprivate let _appConfigurationsChangedSubject = PassthroughSubject() - fileprivate lazy var appConfigurationsChangedSubject = { - _appConfigurationsChangedSubject.eraseToAnyPublisher() - }() + fileprivate lazy var appConfigurationsChangedSubject = _appConfigurationsChangedSubject.eraseToAnyPublisher() init(userDefaults: UserDefaults) { self.userDefaults = userDefaults @@ -25,6 +22,9 @@ final class AppConfigurations { @UserDefault("enable_pip") var enablePiP: Bool = false + + @UserDefault("enable_multichannel") + var enableMultiChannel: Bool = false } @propertyWrapper @@ -64,7 +64,6 @@ struct UserDefault { @propertyWrapper struct AppConfiguration: DynamicProperty { - @ObservedObject private var appConfigurationsObserver: PublisherObservableObject private let keyPath: ReferenceWritableKeyPath private let appConfigurations: AppConfigurations @@ -95,11 +94,10 @@ struct AppConfiguration: DynamicProperty { } final class PublisherObservableObject: ObservableObject { - var subscriber: AnyCancellable? init(publisher: AnyPublisher) { - subscriber = publisher.sink(receiveValue: { [weak self] _ in + self.subscriber = publisher.sink(receiveValue: { [weak self] _ in self?.objectWillChange.send() }) } diff --git a/interactive-player/Interactive Viewer/Views/ChannelDetailInputScreen/ChannelDetailInputView.swift b/interactive-player/Interactive Viewer/Views/ChannelDetailInputScreen/ChannelDetailInputView.swift new file mode 100644 index 00000000..c904b929 --- /dev/null +++ b/interactive-player/Interactive Viewer/Views/ChannelDetailInputScreen/ChannelDetailInputView.swift @@ -0,0 +1,135 @@ +// +// ChannelDetailInputView.swift +// + +import DolbyIOUIKit +import MillicastSDK +import RTSCore +import SwiftUI + +struct ChannelDetailInputView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.presentationMode) private var presentationMode + @ObservedObject var viewModel: ChannelDetailInputViewModel + @State var additionalInputCount: Int = 0 + + init(viewModel: ChannelDetailInputViewModel) { + self.viewModel = viewModel + } + + var body: some View { + ScrollView { + VStack(alignment: .center, spacing: 0) { + if horizontalSizeClass == .regular { + Spacer() + .frame(height: Layout.spacing5x) + } else { + Spacer() + .frame(height: Layout.spacing3x) + } + + Text( + "stream-detail-input.title.label", + style: .labelMedium, + font: .custom("AvenirNext-DemiBold", size: FontSize.title1, relativeTo: .title) + ) + + Spacer() + .frame(height: Layout.spacing3x) + + Text( + "channel-detail-input.start-a-channel.label", + style: .labelMedium, + font: .custom("AvenirNext-DemiBold", size: FontSize.title2, relativeTo: .title) + ) + + Spacer() + .frame(height: Layout.spacing1x) + + Text( + "channel-detail-input.subtitle.label", + font: .custom("AvenirNext-Regular", size: FontSize.subhead, relativeTo: .subheadline) + ) + .multilineTextAlignment(.center) + + Spacer() + .frame(height: Layout.spacing3x) + + VStack(alignment: .trailing, spacing: Layout.spacing1x) { + channelInput(labelName: "channel-detail-input.channel-1.label", + streamName: $viewModel.streamName1, + accountID: $viewModel.accountID1, + showPlus: false) + + channelInput(labelName: "channel-detail-input.channel-2.label", + streamName: $viewModel.streamName2, + accountID: $viewModel.accountID2, + showPlus: additionalInputCount == 0) + + if additionalInputCount > 0 { + channelInput(labelName: "channel-detail-input.channel-3.label", + streamName: $viewModel.streamName3, + accountID: $viewModel.accountID3, + showPlus: additionalInputCount == 1) + } + + if additionalInputCount > 1 { + channelInput(labelName: "channel-detail-input.channel-4.label", + streamName: $viewModel.streamName4, + accountID: $viewModel.accountID4, + showPlus: false) + .padding(.bottom, Layout.spacing2x) + } + + Button( + action: { + viewModel.playButtonPressed() + }, + text: "stream-detail-input.play.button" + ) + } + .frame(maxWidth: 400) + } + .padding([.leading, .trailing], Layout.spacing3x) + } + } + + @ViewBuilder + private func channelInput( + labelName: LocalizedStringKey, + streamName: Binding, + accountID: Binding, + showPlus: Bool + ) -> some View { + VStack(alignment: .leading, spacing: Layout.spacing2x) { + Text( + labelName, + font: .custom("AvenirNext-Bold", size: FontSize.subhead, relativeTo: .subheadline) + ) + .multilineTextAlignment(.leading) + .padding(.top, Layout.spacing3x) + + DolbyIOUIKit.TextField(text: streamName, placeholderText: "stream-detail-streamname-placeholder-label") + .accessibilityIdentifier("InputScreen.StreamNameInput") + .font(.avenirNextRegular(withStyle: .caption, size: FontSize.caption1)) + + DolbyIOUIKit.TextField(text: accountID, placeholderText: "stream-detail-accountid-placeholder-label") + .accessibilityIdentifier("InputScreen.AccountIDInput") + .font(.avenirNextRegular(withStyle: .caption, size: FontSize.caption1)) + } + + if showPlus { + VStack(alignment: .trailing) { + IconButton( + iconAsset: .plus + ) { + additionalInputCount += 1 + } + } + } + } +} + +#Preview { + ChannelDetailInputView(viewModel: ChannelDetailInputViewModel(channels: .constant(nil), showChannelView: .constant(false), streamDataManager: StreamDataManager.shared, dateProvider: DefaultDateProvider())) +} diff --git a/interactive-player/Interactive Viewer/Views/ChannelDetailInputScreen/ChannelDetailInputViewModel.swift b/interactive-player/Interactive Viewer/Views/ChannelDetailInputScreen/ChannelDetailInputViewModel.swift new file mode 100644 index 00000000..daa252b1 --- /dev/null +++ b/interactive-player/Interactive Viewer/Views/ChannelDetailInputScreen/ChannelDetailInputViewModel.swift @@ -0,0 +1,209 @@ +// +// ChannelDetailInputViewModel.swift +// + +import Combine +import Foundation +import MillicastSDK +import RTSCore +import SwiftUI + +@MainActor +final class ChannelDetailInputViewModel: ObservableObject { + @Binding private var channels: [Channel]? + @Binding private var showChannelView: Bool + @Published var streamName1: String = "game" + @Published var accountID1: String = "7csQUs" + @Published var streamName2: String = "multiview" + @Published var accountID2: String = "k9Mwad" + @Published var streamName3: String = "" + @Published var accountID3: String = "" + @Published var streamName4: String = "" + @Published var accountID4: String = "" + @Published var validationError: ValidationError? + @Published var showAlert = false + + private let streamDataManager: StreamDataManagerProtocol + private let dateProvider: DateProvider + private let api = SubscriptionConfiguration.Constants.productionSubscribeURL + private let videoJitterMinimumDelayInMs = UInt(SubscriptionConfiguration.Constants.jitterMinimumDelayMs) + private let minPlayoutDelay = UInt(SubscriptionConfiguration.Constants.playoutDelay.minimum) + private let maxPlayoutDelay = UInt(SubscriptionConfiguration.Constants.playoutDelay.maximum) + private let maxBitrate: UInt = .init(SubscriptionConfiguration.Constants.maxBitrate) + private let duration = SubscriptionConfiguration.Constants.bweMonitorDurationUs + private let waitTime = SubscriptionConfiguration.Constants.upwardsLayerWaitTimeMs + private let forceSmooth: Bool = SubscriptionConfiguration.Constants.forceSmooth + private let monitorDurationString: String = "\(SubscriptionConfiguration.Constants.bweMonitorDurationUs)" + private let rateChangePercentage: Float = SubscriptionConfiguration.Constants.bweRateChangePercentage + private let upwardsLayerWaitTimeString: String = "\(SubscriptionConfiguration.Constants.upwardsLayerWaitTimeMs)" + + init( + channels: Binding<[Channel]?>, + showChannelView: Binding, + streamDataManager: StreamDataManagerProtocol = StreamDataManager.shared, + dateProvider: DateProvider = DefaultDateProvider() + ) { + self._channels = channels + self._showChannelView = showChannelView + self.streamDataManager = streamDataManager + self.dateProvider = dateProvider + } + + func playButtonPressed() { + var confirmedChannels = [Channel]() + let streamDetails = createStreamDetailArray() + for detail in streamDetails { + guard let channel = setupChannel(for: detail) else { return } + confirmedChannels.append(channel) + } + + channels = confirmedChannels + showChannelView = true + } + + func clearAllStreams() { + streamDataManager.clearAllStreams() + } +} + +private extension ChannelDetailInputViewModel { + func createStreamDetailArray() -> [StreamDetail] { + var streamDetails = [StreamDetail]() + if !streamName1.isEmpty, !accountID1.isEmpty { + let streamDetail = StreamDetail(streamName: streamName1, accountID: accountID1) + streamDetails.append(streamDetail) + } + if !streamName2.isEmpty, !accountID2.isEmpty { + let streamDetail = StreamDetail(streamName: streamName2, accountID: accountID2) + streamDetails.append(streamDetail) + } + if !streamName3.isEmpty, !accountID3.isEmpty { + let streamDetail = StreamDetail(streamName: streamName3, accountID: accountID3) + streamDetails.append(streamDetail) + } + if !streamName4.isEmpty, !accountID4.isEmpty { + let streamDetail = StreamDetail(streamName: streamName4, accountID: accountID4) + streamDetails.append(streamDetail) + } + return streamDetails + } + + func setupChannel(for streamDetail: StreamDetail) -> Channel? { + let success = validateAndSaveStream( + streamName: streamDetail.streamName, + accountID: streamDetail.accountID, + subscribeAPI: api, + videoJitterMinimumDelayInMs: videoJitterMinimumDelayInMs, + minPlayoutDelay: minPlayoutDelay, + maxPlayoutDelay: maxPlayoutDelay, + maxBitrate: maxBitrate, + forceSmooth: forceSmooth, + monitorDuration: duration, + rateChangePercentage: rateChangePercentage, + upwardLayerWaitTime: waitTime, + disableAudio: false, + primaryVideoQuality: .auto, + saveLogs: false, + persistStream: true + ) + + guard success else { return nil } + + let configuration = configuration( + subscribeAPI: api, + videoJitterMinimumDelayInMs: videoJitterMinimumDelayInMs, + minPlayoutDelay: minPlayoutDelay, + maxPlayoutDelay: maxPlayoutDelay, + maxBitrate: maxBitrate, + disableAudio: false, + primaryVideoQuality: .auto, + saveLogs: false + ) + + let subscriptionManager1 = SubscriptionManager() + return Channel( + streamDetail: streamDetail, + listViewPrimaryVideoQuality: .auto, + configuration: configuration, + subscriptionManager: subscriptionManager1, + videoTracksManager: VideoTracksManager(subscriptionManager: subscriptionManager1) + ) + } + + // swiftlint:disable function_parameter_count + func validateAndSaveStream( + streamName: String, + accountID: String, + subscribeAPI: String, + videoJitterMinimumDelayInMs: UInt, + minPlayoutDelay: UInt, + maxPlayoutDelay: UInt, + maxBitrate: UInt, + forceSmooth: Bool, + monitorDuration: UInt, + rateChangePercentage: Float, + upwardLayerWaitTime: UInt, + disableAudio: Bool, + primaryVideoQuality: VideoQuality, + saveLogs: Bool, + persistStream: Bool + ) -> Bool { + guard !streamName.isEmpty, !accountID.isEmpty else { + validationError = .emptyStreamNameOrAccountID + return false + } + // TODO: Need to set up persistance on channels + if persistStream { + streamDataManager.saveStream( + SavedStreamDetail( + accountID: accountID, + streamName: streamName, + subscribeAPI: subscribeAPI, + videoJitterMinimumDelayInMs: videoJitterMinimumDelayInMs, + minPlayoutDelay: minPlayoutDelay, + maxPlayoutDelay: maxPlayoutDelay, + disableAudio: disableAudio, + primaryVideoQuality: primaryVideoQuality, + maxBitrate: maxBitrate, + forceSmooth: forceSmooth, + monitorDuration: monitorDuration, + rateChangePercentage: rateChangePercentage, + upwardsLayerWaitTimeMs: upwardLayerWaitTime, + saveLogs: saveLogs + ) + ) + } + return true + } + + func configuration( + subscribeAPI: String, + videoJitterMinimumDelayInMs: UInt, + minPlayoutDelay: UInt?, + maxPlayoutDelay: UInt?, + maxBitrate: UInt, + disableAudio: Bool, + primaryVideoQuality: VideoQuality, + saveLogs: Bool + ) -> SubscriptionConfiguration { + var playoutDelay: MCForcePlayoutDelay = SubscriptionConfiguration.Constants.playoutDelay + if let minPlayoutDelay, let maxPlayoutDelay { + playoutDelay = MCForcePlayoutDelay(min: Int32(minPlayoutDelay), max: Int32(maxPlayoutDelay)) + } + let currentDate = dateProvider.now + let rtcLogPath = saveLogs ? URL.rtcLogPath(for: currentDate) : nil + let sdkLogPath = saveLogs ? URL.sdkLogPath(for: currentDate) : nil + + return SubscriptionConfiguration( + subscribeAPI: subscribeAPI, + jitterMinimumDelayMs: videoJitterMinimumDelayInMs, + maxBitrate: maxBitrate, + disableAudio: disableAudio, + rtcEventLogPath: rtcLogPath?.path, + sdkLogPath: sdkLogPath?.path, + playoutDelay: playoutDelay + ) + } + + // swiftlint:enable function_parameter_count +} diff --git a/interactive-player/Interactive Viewer/Views/ChannelGridView/ChannelGridView.swift b/interactive-player/Interactive Viewer/Views/ChannelGridView/ChannelGridView.swift new file mode 100644 index 00000000..bf6db450 --- /dev/null +++ b/interactive-player/Interactive Viewer/Views/ChannelGridView/ChannelGridView.swift @@ -0,0 +1,83 @@ +// +// ChannelGridView.swift +// + +import DolbyIOUIKit +import MillicastSDK +import RTSCore +import SwiftUI + +struct ChannelGridView: View { + @State private var deviceOrientation: UIDeviceOrientation = .portrait + + private let viewModel: ChannelGridViewModel + + private enum Defaults { + static let numberOfColumnsForPortrait = 1 + static let numberOfColumnsForLandscape = 2 + } + + init(viewModel: ChannelGridViewModel) { + self.viewModel = viewModel + } + + var body: some View { + GeometryReader { proxy in + let screenSize = proxy.size + let numberOfColumns = deviceOrientation.isPortrait ? Defaults.numberOfColumnsForPortrait : Defaults.numberOfColumnsForLandscape + let tileWidth = screenSize.width / CGFloat(numberOfColumns) + let columns = [GridItem](repeating: GridItem(.flexible(), spacing: Layout.spacing1x), count: numberOfColumns) + ScrollView { + LazyVGrid(columns: columns, alignment: .leading) { + ForEach(viewModel.channels) { channel in + let source = channel.source + let displayLabel = source.sourceId.displayLabel + let preferredVideoQuality: VideoQuality = .auto + + let viewId = "\(ChannelGridView.self).\(displayLabel)" + + VideoRendererView( + source: source, + isSelectedVideoSource: true, + isSelectedAudioSource: true, + isPiPView: false, + showSourceLabel: false, + showAudioIndicator: false, + maxWidth: tileWidth, + maxHeight: .infinity, + accessibilityIdentifier: "ChannelGridViewVideoTile.\(source.sourceId.displayLabel)", + preferredVideoQuality: preferredVideoQuality, + subscriptionManager: channel.subscriptionManager, + videoTracksManager: channel.videoTracksManager, + action: { _ in } + ) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + .onAppear { + GridViewModel.logger.debug("♼ Channel Grid view: Video view appear for \(source.sourceId)") + Task { + await channel.videoTracksManager.enableTrack(for: source, with: preferredVideoQuality, on: viewId) + } + } + .onDisappear { + GridViewModel.logger.debug("♼ Channel Grid view: Video view disappear for \(source.sourceId)") + Task { + await channel.videoTracksManager.disableTrack(for: source, on: viewId) + } + } + .id(source.id) + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + .onRotate { newOrientation in + if !newOrientation.isFlat && newOrientation.isValidInterfaceOrientation { + deviceOrientation = newOrientation + } + } + } + } +} + + #Preview { + ChannelGridView(viewModel: ChannelGridViewModel(channels: [])) + } diff --git a/interactive-player/Interactive Viewer/Views/ChannelGridView/ChannelGridViewModel.swift b/interactive-player/Interactive Viewer/Views/ChannelGridView/ChannelGridViewModel.swift new file mode 100644 index 00000000..f1fa570c --- /dev/null +++ b/interactive-player/Interactive Viewer/Views/ChannelGridView/ChannelGridViewModel.swift @@ -0,0 +1,17 @@ +// +// ChannelGridViewModel.swift +// + +import Foundation +import MillicastSDK +import os +import RTSCore +import SwiftUI + +final class ChannelGridViewModel: ObservableObject { + let channels: [SourcedChannel] + + init(channels: [SourcedChannel]) { + self.channels = channels + } +} diff --git a/interactive-player/Interactive Viewer/Views/Components/AppSettings/AppSettingsView.swift b/interactive-player/Interactive Viewer/Views/Components/AppSettings/AppSettingsView.swift index ab77a0f7..7da0ee20 100644 --- a/interactive-player/Interactive Viewer/Views/Components/AppSettings/AppSettingsView.swift +++ b/interactive-player/Interactive Viewer/Views/Components/AppSettings/AppSettingsView.swift @@ -11,6 +11,7 @@ import DolbyIOUIKit struct AppSettingsView: View { @AppConfiguration(\.showDebugFeatures) var showDebugFeatures @AppConfiguration(\.enablePiP) var enablePiP + @AppConfiguration(\.enableMultiChannel) var enableMultiChannel var body: some View { // Custom App Configurations @@ -29,6 +30,14 @@ struct AppSettingsView: View { font: .custom("AvenirNext-Regular", size: FontSize.body) ) } + + Toggle(isOn: $enableMultiChannel) { + Text( + "app-configuration-enable-multichannel-label", + style: .titleMedium, + font: .custom("AvenirNext-Regular", size: FontSize.body) + ) + } } } diff --git a/interactive-player/Interactive Viewer/Views/LandingView/LandingView.swift b/interactive-player/Interactive Viewer/Views/LandingView/LandingView.swift index 24739d0b..d86cc748 100644 --- a/interactive-player/Interactive Viewer/Views/LandingView/LandingView.swift +++ b/interactive-player/Interactive Viewer/Views/LandingView/LandingView.swift @@ -2,8 +2,8 @@ // LandingView.swift // -import RTSCore import DolbyIOUIKit +import RTSCore import SwiftUI struct Stream { @@ -12,13 +12,17 @@ struct Stream { } struct LandingView: View { + @AppConfiguration(\.enableMultiChannel) private var enableMultiChannel @StateObject private var viewModel: LandingViewModel = .init() - @StateObject private var recentStreamsViewModel: RecentStreamsViewModel = RecentStreamsViewModel() + @StateObject private var recentStreamsViewModel: RecentStreamsViewModel = .init() @State private var isShowingStreamInputView = false + @State private var isShowingChannelInputView = false @State private var isShowingFullStreamHistoryView: Bool = false @State private var isShowingSettingsView: Bool = false @State private var streamingScreenContext: StreamingView.Context? + @State private var channels: [Channel]? + @State private var showChannelView: Bool = false @EnvironmentObject private var appState: AppState @@ -27,36 +31,40 @@ struct LandingView: View { NavigationLink( destination: StreamDetailInputScreen(streamingScreenContext: $streamingScreenContext), isActive: $isShowingStreamInputView) { - EmptyView() - } - .hidden() + EmptyView() + } + .hidden() NavigationLink( destination: SavedStreamsScreen(viewModel: recentStreamsViewModel), isActive: $isShowingFullStreamHistoryView) { - EmptyView() - } - .hidden() + EmptyView() + } + .hidden() NavigationLink( destination: SettingsScreen(mode: .global, moreSettings: { AppSettingsView() }), isActive: $isShowingSettingsView) { - EmptyView() - } - .hidden() + EmptyView() + } + .hidden() - if viewModel.hasSavedStreams { - RecentStreamsScreen( - viewModel: recentStreamsViewModel, - isShowingStreamInputView: $isShowingStreamInputView, - isShowingFullStreamHistoryView: $isShowingFullStreamHistoryView, - isShowingSettingsView: $isShowingSettingsView, - streamingScreenContext: $streamingScreenContext - ) + if enableMultiChannel { + let viewModel = ChannelDetailInputViewModel(channels: $channels, showChannelView: $showChannelView) + ChannelDetailInputView(viewModel: viewModel) } else { - StreamDetailInputScreen(streamingScreenContext: $streamingScreenContext) + if viewModel.hasSavedStreams { + RecentStreamsScreen( + viewModel: recentStreamsViewModel, + isShowingStreamInputView: $isShowingStreamInputView, + isShowingFullStreamHistoryView: $isShowingFullStreamHistoryView, + isShowingSettingsView: $isShowingSettingsView, + streamingScreenContext: $streamingScreenContext) + } else { + StreamDetailInputScreen(streamingScreenContext: $streamingScreenContext) + } } } .navigationBarTitleDisplayMode(.inline) @@ -69,8 +77,8 @@ struct LandingView: View { SettingsButton { isShowingSettingsView = true } .accessibilityIdentifier( viewModel.hasSavedStreams - ? "RecentScreen.SettingButton" - : "InputScreen.SettingButton" + ? "RecentScreen.SettingButton" + : "InputScreen.SettingButton" ) } @@ -89,6 +97,16 @@ struct LandingView: View { streamingScreenContext = nil } } + + .fullScreenCover(isPresented: $showChannelView, content: { + if let channels { + let channelViewModel = ChannelViewModel(channels: $channels) + ChannelView(viewModel: channelViewModel, onClose: { + self.channels = nil + self.showChannelView = false + }) + } + }) } } diff --git a/interactive-player/Interactive Viewer/Views/StreamDetailInputScreen/StreamDetailInputViewModel.swift b/interactive-player/Interactive Viewer/Views/StreamDetailInputScreen/StreamDetailInputViewModel.swift index 00e95dfa..839bb553 100644 --- a/interactive-player/Interactive Viewer/Views/StreamDetailInputScreen/StreamDetailInputViewModel.swift +++ b/interactive-player/Interactive Viewer/Views/StreamDetailInputScreen/StreamDetailInputViewModel.swift @@ -7,25 +7,24 @@ import Foundation import MillicastSDK import RTSCore -@MainActor -final class StreamDetailInputViewModel: ObservableObject { - private let streamDataManager: StreamDataManagerProtocol - private let dateProvider: DateProvider - - enum ValidationError: LocalizedError { - case emptyStreamNameOrAccountID - case failedToConnect +enum ValidationError: LocalizedError { + case emptyStreamNameOrAccountID + case failedToConnect - var errorDescription: String? { - switch self { - case .emptyStreamNameOrAccountID: - return String(localized: "stream-detail-input.empty-credentials-error.label") - case .failedToConnect: - return String(localized: "stream-detail-input.connection-failure.label") - } + var errorDescription: String? { + switch self { + case .emptyStreamNameOrAccountID: + return String(localized: "stream-detail-input.empty-credentials-error.label") + case .failedToConnect: + return String(localized: "stream-detail-input.connection-failure.label") } } +} +@MainActor +final class StreamDetailInputViewModel: ObservableObject { + private let streamDataManager: StreamDataManagerProtocol + private let dateProvider: DateProvider @Published var validationError: ValidationError? init( diff --git a/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/plus.imageset/Contents.json b/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/plus.imageset/Contents.json new file mode 100644 index 00000000..8db35d28 --- /dev/null +++ b/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/plus.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "plus.circle.fill.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/plus.imageset/plus.circle.fill.svg b/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/plus.imageset/plus.circle.fill.svg new file mode 100644 index 00000000..dbad3c30 --- /dev/null +++ b/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/plus.imageset/plus.circle.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Theme/Asset/IconAsset.swift b/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Theme/Asset/IconAsset.swift index daaa43c1..d991658a 100644 --- a/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Theme/Asset/IconAsset.swift +++ b/interactive-player/LocalPackages/DolbyIOUIKit/Sources/DolbyIOUIKit/Theme/Asset/IconAsset.swift @@ -38,4 +38,5 @@ public enum IconAsset: String { case simulcast = "simulcast" case fullScreen = "full-screen" case exitFullScreen = "exit-full-screen" + case plus = "plus" } diff --git a/interactive-player/RTSViewer.xcodeproj/project.pbxproj b/interactive-player/RTSViewer.xcodeproj/project.pbxproj index a3403e59..7b5d1329 100644 --- a/interactive-player/RTSViewer.xcodeproj/project.pbxproj +++ b/interactive-player/RTSViewer.xcodeproj/project.pbxproj @@ -8,6 +8,13 @@ /* Begin PBXBuildFile section */ 896AECC02C812225002CD12D /* V1toV2.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 896AECBF2C812225002CD12D /* V1toV2.xcmappingmodel */; }; + 89C391AF2C8A3BE400861FD5 /* ChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391AE2C8A3BE400861FD5 /* ChannelView.swift */; }; + 89C391B12C8A3C1900861FD5 /* ChannelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391B02C8A3C1900861FD5 /* ChannelViewModel.swift */; }; + 89C391B42C8F58E900861FD5 /* ChannelDetailInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391B32C8F58E900861FD5 /* ChannelDetailInputView.swift */; }; + 89C391B62C8F58F800861FD5 /* ChannelDetailInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391B52C8F58F800861FD5 /* ChannelDetailInputViewModel.swift */; }; + 89C391B92C8F93A100861FD5 /* ChannelGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391B82C8F93A100861FD5 /* ChannelGridView.swift */; }; + 89C391BB2C8F93B300861FD5 /* ChannelGridViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391BA2C8F93B300861FD5 /* ChannelGridViewModel.swift */; }; + 89C391BD2C939F2000861FD5 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391BC2C939F2000861FD5 /* Channel.swift */; }; B6376BC22A1C4120007AD9D9 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6376BC12A1C4120007AD9D9 /* Constants.swift */; }; E82112CE2C2A33150003DBF7 /* VideoQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82112CB2C2A33140003DBF7 /* VideoQuality.swift */; }; E82112CF2C2A33150003DBF7 /* StreamSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82112CA2C2A33140003DBF7 /* StreamSettings.swift */; }; @@ -91,6 +98,13 @@ /* Begin PBXFileReference section */ 3FE90C1B26B2AF4200B206A3 /* RTSViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTSViewer.swift; sourceTree = ""; }; 896AECBF2C812225002CD12D /* V1toV2.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = V1toV2.xcmappingmodel; sourceTree = ""; }; + 89C391AE2C8A3BE400861FD5 /* ChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelView.swift; sourceTree = ""; }; + 89C391B02C8A3C1900861FD5 /* ChannelViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelViewModel.swift; sourceTree = ""; }; + 89C391B32C8F58E900861FD5 /* ChannelDetailInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailInputView.swift; sourceTree = ""; }; + 89C391B52C8F58F800861FD5 /* ChannelDetailInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailInputViewModel.swift; sourceTree = ""; }; + 89C391B82C8F93A100861FD5 /* ChannelGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelGridView.swift; sourceTree = ""; }; + 89C391BA2C8F93B300861FD5 /* ChannelGridViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelGridViewModel.swift; sourceTree = ""; }; + 89C391BC2C939F2000861FD5 /* Channel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = ""; }; B6376BC12A1C4120007AD9D9 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; E8067F482A329F680083E94B /* InteractiveVieweriOSFirebaseReleaseSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = InteractiveVieweriOSFirebaseReleaseSettings.xcconfig; sourceTree = ""; }; E82112C72C2A330C0003DBF7 /* PiPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiPManager.swift; sourceTree = ""; }; @@ -195,6 +209,34 @@ name = Products; sourceTree = ""; }; + 89C391AD2C8A3BB500861FD5 /* ChannelView */ = { + isa = PBXGroup; + children = ( + 89C391AE2C8A3BE400861FD5 /* ChannelView.swift */, + 89C391B02C8A3C1900861FD5 /* ChannelViewModel.swift */, + ); + name = ChannelView; + path = ../ChannelView; + sourceTree = ""; + }; + 89C391B22C8F58C000861FD5 /* ChannelDetailInputScreen */ = { + isa = PBXGroup; + children = ( + 89C391B32C8F58E900861FD5 /* ChannelDetailInputView.swift */, + 89C391B52C8F58F800861FD5 /* ChannelDetailInputViewModel.swift */, + ); + path = ChannelDetailInputScreen; + sourceTree = ""; + }; + 89C391B72C8F938A00861FD5 /* ChannelGridView */ = { + isa = PBXGroup; + children = ( + 89C391B82C8F93A100861FD5 /* ChannelGridView.swift */, + 89C391BA2C8F93B300861FD5 /* ChannelGridViewModel.swift */, + ); + path = ChannelGridView; + sourceTree = ""; + }; E82112C62C2A330C0003DBF7 /* Managers */ = { isa = PBXGroup; children = ( @@ -220,6 +262,9 @@ E82112E72C2A339D0003DBF7 /* StatsInfo */, E83CD9DE2A10878C008690FD /* StreamDetailInputScreen */, E82112F82C2A339D0003DBF7 /* StreamingView */, + 89C391B22C8F58C000861FD5 /* ChannelDetailInputScreen */, + 89C391AD2C8A3BB500861FD5 /* ChannelView */, + 89C391B72C8F938A00861FD5 /* ChannelGridView */, ); path = Views; sourceTree = ""; @@ -438,6 +483,7 @@ E82112CA2C2A33140003DBF7 /* StreamSettings.swift */, E82112CB2C2A33140003DBF7 /* VideoQuality.swift */, E87FD1892AD8E1EF00E9164A /* SavedStreamDetail.swift */, + 89C391BC2C939F2000861FD5 /* Channel.swift */, ); path = Models; sourceTree = ""; @@ -585,6 +631,7 @@ E82113182C2A37C30003DBF7 /* SettingsButton.swift in Sources */, E82113212C2A37D00003DBF7 /* StatisticsInfoViewModel.swift in Sources */, E821130C2C2A378A0003DBF7 /* VideoRendererViewModel.swift in Sources */, + 89C391BD2C939F2000861FD5 /* Channel.swift in Sources */, E82113192C2A37C30003DBF7 /* SettingsScreen.swift in Sources */, E87FD1C92ADCFED400E9164A /* DateProvider.swift in Sources */, E82112D72C2A331C0003DBF7 /* UserDefaults+Codable.swift in Sources */, @@ -594,11 +641,13 @@ E87FD0C72AD8DE4600E9164A /* ImageAsset.swift in Sources */, E821131F2C2A37C90003DBF7 /* UserInteractionViewModel.swift in Sources */, E82113112C2A37930003DBF7 /* ErrorView.swift in Sources */, + 89C391BB2C8F93B300861FD5 /* ChannelGridViewModel.swift in Sources */, E82113102C2A37900003DBF7 /* LiveIndicatorView.swift in Sources */, E82113232C2A37D80003DBF7 /* StreamViewModel.swift in Sources */, E821130D2C2A378A0003DBF7 /* VideoRendererView.swift in Sources */, E821130F2C2A37900003DBF7 /* LiveIndicatorViewModel.swift in Sources */, E83CD9F92A10878C008690FD /* StreamDetailInputScreen.swift in Sources */, + 89C391B62C8F58F800861FD5 /* ChannelDetailInputViewModel.swift in Sources */, E846A8A22C785D220052D6C8 /* V2MigrationPolicy.swift in Sources */, E82112CF2C2A33150003DBF7 /* StreamSettings.swift in Sources */, E82112D82C2A331C0003DBF7 /* UserDefaultsBacked.swift in Sources */, @@ -606,9 +655,12 @@ B6376BC22A1C4120007AD9D9 /* Constants.swift in Sources */, E874A0312AE1FD07003C3FF8 /* AppSettingsView.swift in Sources */, E82113122C2A37970003DBF7 /* BackButton.swift in Sources */, + 89C391AF2C8A3BE400861FD5 /* ChannelView.swift in Sources */, E82113242C2A37D80003DBF7 /* StreamingView.swift in Sources */, E83CDA0C2A10878C008690FD /* SavedStreamsScreen.swift in Sources */, E821131B2C2A37C30003DBF7 /* SettingsStreamSortOrderScreen.swift in Sources */, + 89C391B42C8F58E900861FD5 /* ChannelDetailInputView.swift in Sources */, + 89C391B92C8F93A100861FD5 /* ChannelGridView.swift in Sources */, E821130E2C2A378A0003DBF7 /* SourceLabel.swift in Sources */, E846A89A2C784F720052D6C8 /* RTSViewer.xcdatamodeld in Sources */, E82112D62C2A331C0003DBF7 /* SourceId+Display.swift in Sources */, @@ -620,6 +672,7 @@ E821131D2C2A37C90003DBF7 /* SingleStreamViewModel.swift in Sources */, E821130B2C2A37700003DBF7 /* PiPManager.swift in Sources */, E83CD9FE2A10878C008690FD /* SplashScreen.swift in Sources */, + 89C391B12C8A3C1900861FD5 /* ChannelViewModel.swift in Sources */, E83CD9FB2A10878C008690FD /* LandingViewModel.swift in Sources */, E82113092C2A37680003DBF7 /* SettingsManager.swift in Sources */, E8264D682A25B32D00660507 /* RTSViewer.swift in Sources */, diff --git a/rts-viewer-tvos/.DS_Store b/rts-viewer-tvos/.DS_Store index 615de75c..203ba144 100644 Binary files a/rts-viewer-tvos/.DS_Store and b/rts-viewer-tvos/.DS_Store differ