Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DIOS-6734: MulitChannels (DO NOT MERGE) #216

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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<Void, some View> {
ToolbarItem(placement: .navigationBarLeading) {
IconButton(iconAsset: .close) {
onClose()
viewModel.endStream()
}
}
}
}

#Preview {
ChannelView(viewModel: ChannelViewModel(channels: .constant([])), onClose: {})
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
11 changes: 11 additions & 0 deletions interactive-player/Interactive Viewer/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 */

Expand Down
38 changes: 38 additions & 0 deletions interactive-player/Interactive Viewer/Models/Channel.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyKeyPath, Never>()
fileprivate lazy var appConfigurationsChangedSubject = {
_appConfigurationsChangedSubject.eraseToAnyPublisher()
}()
fileprivate lazy var appConfigurationsChangedSubject = _appConfigurationsChangedSubject.eraseToAnyPublisher()

init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
Expand All @@ -25,6 +22,9 @@ final class AppConfigurations {

@UserDefault("enable_pip")
var enablePiP: Bool = false

@UserDefault("enable_multichannel")
var enableMultiChannel: Bool = false
}

@propertyWrapper
Expand Down Expand Up @@ -64,7 +64,6 @@ struct UserDefault<Value> {

@propertyWrapper
struct AppConfiguration<Value>: DynamicProperty {

@ObservedObject private var appConfigurationsObserver: PublisherObservableObject
private let keyPath: ReferenceWritableKeyPath<AppConfigurations, Value>
private let appConfigurations: AppConfigurations
Expand Down Expand Up @@ -95,11 +94,10 @@ struct AppConfiguration<Value>: DynamicProperty {
}

final class PublisherObservableObject: ObservableObject {

var subscriber: AnyCancellable?

init(publisher: AnyPublisher<Void, Never>) {
subscriber = publisher.sink(receiveValue: { [weak self] _ in
self.subscriber = publisher.sink(receiveValue: { [weak self] _ in
self?.objectWillChange.send()
})
}
Expand Down
Loading
Loading