diff --git a/LocalPackages/RTSCore/Sources/RTSCore/Manager/SubscriptionManager.swift b/LocalPackages/RTSCore/Sources/RTSCore/Manager/SubscriptionManager.swift index 802231c2..643d24d9 100644 --- a/LocalPackages/RTSCore/Sources/RTSCore/Manager/SubscriptionManager.swift +++ b/LocalPackages/RTSCore/Sources/RTSCore/Manager/SubscriptionManager.swift @@ -100,11 +100,10 @@ public actor SubscriptionManager { public func unSubscribe() async throws { Self.logger.debug("👨‍🔧 Stop subscription") await subscriber.enableStats(false) + reset() try await subscriber.unsubscribe() try await subscriber.disconnect() Self.logger.debug("👨‍🔧 Successfully stopped subscription") - - reset() } private func reset() { diff --git a/interactive-player/Interactive Viewer/Views/StatsInfo/StatisticsInfoViewModel.swift b/interactive-player/Interactive Viewer/Views/StatsInfo/StatisticsInfoViewModel.swift index 22bd4012..0efa60e7 100644 --- a/interactive-player/Interactive Viewer/Views/StatsInfo/StatisticsInfoViewModel.swift +++ b/interactive-player/Interactive Viewer/Views/StatsInfo/StatisticsInfoViewModel.swift @@ -479,11 +479,11 @@ private extension StatsInfoViewModel { static func formatBitRate(bitRate: Double) -> String { if bitRate < KILOBITS { - "\(bitRate)bps" + "\(bitRate) bps" } else if bitRate >= KILOBITS && bitRate < MEGABITS { - "\((bitRate / KILOBITS).rounded(toPlaces: 4))Kbps" + "\((bitRate / KILOBITS).rounded(toPlaces: 4)) Kbps" } else { - "\((bitRate / MEGABITS).rounded(toPlaces: 4))Mbps" + "\((bitRate / MEGABITS).rounded(toPlaces: 4)) Mbps" } } diff --git a/interactive-player/Interactive Viewer/Views/StreamingView/StreamViewModel.swift b/interactive-player/Interactive Viewer/Views/StreamingView/StreamViewModel.swift index e896321a..5c7a41aa 100644 --- a/interactive-player/Interactive Viewer/Views/StreamingView/StreamViewModel.swift +++ b/interactive-player/Interactive Viewer/Views/StreamingView/StreamViewModel.swift @@ -30,7 +30,7 @@ final class StreamViewModel: ObservableObject { selectedAudioSource: StreamSource?, settings: StreamSettings ) - case error(title: String, subtitle: String?) + case error(title: String, subtitle: String?, showLiveIndicator: Bool) } let subscriptionManager: SubscriptionManager @@ -166,7 +166,7 @@ final class StreamViewModel: ObservableObject { case .connectionOrder: sortedSources = sources case .alphaNumeric: - sortedSources = sources.sorted { $0 < $1 } + sortedSources = sources.sorted { $0.sourceId.description < $1.sourceId.description } } let selectedVideoSource: StreamSource @@ -288,7 +288,13 @@ private extension StreamViewModel { await self.updateAudioSourceListing(for: activeSources, currentSettings: settings) guard let newState = await self.makeState(from: activeSources, settings: settings) else { Self.logger.debug("🎰 Make state returned without a value") - await self.update(state: .error(title: .offlineErrorTitle, subtitle: .offlineErrorSubtitle)) + await self.update( + state: .error( + title: .offlineErrorTitle, + subtitle: .offlineErrorSubtitle, + showLiveIndicator: true + ) + ) return } await self.update(state: newState) @@ -303,14 +309,26 @@ private extension StreamViewModel { if await !self.isWebsocketConnected { await self.scheduleReconnection() } - await self.update(state: .error(title: .noInternetErrorTitle, subtitle: nil)) + 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() } - await self.update(state: .error(title: .offlineErrorTitle, subtitle: .offlineErrorSubtitle)) + await self.update( + state: .error( + title: .offlineErrorTitle, + subtitle: .offlineErrorSubtitle, + showLiveIndicator: true + ) + ) } } } diff --git a/interactive-player/Interactive Viewer/Views/StreamingView/StreamingView.swift b/interactive-player/Interactive Viewer/Views/StreamingView/StreamingView.swift index e4492305..f6083016 100644 --- a/interactive-player/Interactive Viewer/Views/StreamingView/StreamingView.swift +++ b/interactive-player/Interactive Viewer/Views/StreamingView/StreamingView.swift @@ -147,14 +147,14 @@ struct StreamingView: View { // swiftlint:enable function_body_length @ViewBuilder - private func errorView(title: String, subtitle: String?) -> some View { + 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 } .overlay(alignment: .topLeading) { - if shouldShowLiveIndicatorView { + if showLiveIndicator { liveIndicatorView } } @@ -249,8 +249,8 @@ struct StreamingView: View { ) case .loading: progressView - case let .error(title: title, subtitle: subtitle): - errorView(title: title, subtitle: subtitle) + case let .error(title: title, subtitle: subtitle, showLiveIndicator: showLiveIndicator): + errorView(title: title, subtitle: subtitle, showLiveIndicator: showLiveIndicator) } } .navigationBarTitleDisplayMode(.inline) diff --git a/interactive-player/RTSViewer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/interactive-player/RTSViewer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8b8a6a14..3cbe83d7 100644 --- a/interactive-player/RTSViewer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/interactive-player/RTSViewer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/millicast/millicast-sdk-swift-package", "state" : { - "revision" : "650bda49e494524b53531dedbd29b60a13477418", - "version" : "2.0.0-beta.6" + "revision" : "d20cb45ff24acbc16191d4df6a0e4be9daa26e24", + "version" : "2.0.0-beta.7" } } ], diff --git a/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj b/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj index 5c7cf66e..b78e14b6 100644 --- a/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj +++ b/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ E8752B69298CA513002D5C2B /* StreamDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8752B68298CA513002D5C2B /* StreamDataManagerTests.swift */; }; E8752B72298CA78D002D5C2B /* MockDateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8752B71298CA78D002D5C2B /* MockDateProvider.swift */; }; E8752B74298CB535002D5C2B /* MockStreamDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8752B73298CB535002D5C2B /* MockStreamDataManager.swift */; }; + E89D28902C747AFE002254AB /* SerialTasks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89D288F2C747AFE002254AB /* SerialTasks.swift */; }; E8B48E86297A2957000DC59A /* RecentStreamButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B48E84297A2957000DC59A /* RecentStreamButton.swift */; }; E8BA8E102991CB0E0043DEE1 /* StreamingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8BA8E0E2991CB0E0043DEE1 /* StreamingViewModel.swift */; }; E8BA8E192991E8B30043DEE1 /* SimulcastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8BA8E172991E8B30043DEE1 /* SimulcastViewModel.swift */; }; @@ -120,6 +121,7 @@ E8752B68298CA513002D5C2B /* StreamDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDataManagerTests.swift; sourceTree = ""; }; E8752B71298CA78D002D5C2B /* MockDateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDateProvider.swift; sourceTree = ""; }; E8752B73298CB535002D5C2B /* MockStreamDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStreamDataManager.swift; sourceTree = ""; }; + E89D288F2C747AFE002254AB /* SerialTasks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SerialTasks.swift; sourceTree = ""; }; E8B48E84297A2957000DC59A /* RecentStreamButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentStreamButton.swift; sourceTree = ""; }; E8BA8E0E2991CB0E0043DEE1 /* StreamingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingViewModel.swift; sourceTree = ""; }; E8BA8E172991E8B30043DEE1 /* SimulcastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulcastViewModel.swift; sourceTree = ""; }; @@ -227,6 +229,7 @@ E82FC5042977CF2A0050777F /* Utils */ = { isa = PBXGroup; children = ( + E89D288F2C747AFE002254AB /* SerialTasks.swift */, E83CDA4F2A1092A3008690FD /* ImageAsset.swift */, E8752B50298C7D02002D5C2B /* DateProvider.swift */, ); @@ -578,6 +581,7 @@ 631DD25826F1E18E0023D24A /* ContentView.swift in Sources */, E83F0F932C192D4B00F6FA6B /* SettingsViewModel.swift in Sources */, E83CDA492A10917A008690FD /* BackgroundContainerView.swift in Sources */, + E89D28902C747AFE002254AB /* SerialTasks.swift in Sources */, 6D61AA07299F51AC004CAF9E /* VideoView.swift in Sources */, E82A0C1E296D0F04007214B8 /* StreamDetailInputView.swift in Sources */, E8752B4B298C72F7002D5C2B /* CoreDataManager.swift in Sources */, diff --git a/rts-viewer-tvos/RTSViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved b/rts-viewer-tvos/RTSViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved index e9986abc..3cbe83d7 100644 --- a/rts-viewer-tvos/RTSViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/rts-viewer-tvos/RTSViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/millicast/millicast-sdk-swift-package", "state" : { - "revision" : "9f95549d5a554a9de5b435149d90f2e0d40724bd", - "version" : "2.0.0-beta.3" + "revision" : "d20cb45ff24acbc16191d4df6a0e4be9daa26e24", + "version" : "2.0.0-beta.7" } } ], diff --git a/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings b/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings index 9de2d314..338ab379 100644 --- a/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings +++ b/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings @@ -86,3 +86,5 @@ "stream.stats.packets-received.label" = "Packets Received"; "stream.stats.timestamp.label" = "Timestamp (GMT)"; "stream.stats.total-stream-time.label" = "Total Stream Time"; +"stream.stats.target-bitrate.label" = "Target Bitrate"; +"stream.stats.outgoing-bitrate.label" = "Outgoing Bitrate"; diff --git a/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsView.swift b/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsView.swift index dc8ee472..ddaa565b 100644 --- a/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsView.swift +++ b/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsView.swift @@ -3,15 +3,26 @@ // import DolbyIOUIKit -import SwiftUI -import RTSCore import Foundation +import MillicastSDK +import RTSCore +import SwiftUI struct StatisticsView: View { private let viewModel: StatisticsViewModel - init(source: StreamSource, streamStatistics: StreamStatistics) { - viewModel = StatisticsViewModel(source: source, streamStatistics: streamStatistics) + init( + source: StreamSource, + streamStatistics: StreamStatistics, + layers: [MCRTSRemoteTrackLayer], + projectedTimeStamp: Double? + ) { + viewModel = StatisticsViewModel( + source: source, + streamStatistics: streamStatistics, + layers: layers, + projectedTimeStamp: projectedTimeStamp + ) } private let fontAssetTable = FontAsset.avenirNextRegular(size: FontSize.caption2, style: .caption2) @@ -31,7 +42,7 @@ struct StatisticsView: View { HStack { Text(text: "stream.stats.name.label", fontAsset: fontAssetCaption) - .frame(maxWidth: 350, alignment: .leading) + .frame(maxWidth: 250, alignment: .leading) Text(text: "stream.stats.value.label", fontAsset: fontAssetCaption) } .frame(maxWidth: .infinity, alignment: .leading) @@ -43,15 +54,15 @@ struct StatisticsView: View { HStack { Text(item.key) .font(theme[fontAssetTable]) - .frame(maxWidth: 350, alignment: .leading) + .frame(maxWidth: 250, alignment: .leading) Text(item.value) .font(fontTable) } .frame(maxWidth: .infinity, alignment: .leading) } } - .frame(maxWidth: 700) - .padding(35) + .frame(maxWidth: 850) + .padding(20) .background { Color(uiColor: UIColor.Neutral.neutral800) .opacity(0.7) diff --git a/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsViewModel.swift b/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsViewModel.swift index edfd059b..acb09479 100644 --- a/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsViewModel.swift +++ b/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsViewModel.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import MillicastSDK import RTSCore import SwiftUI import UIKit @@ -18,22 +19,37 @@ final class StatisticsViewModel: ObservableObject { private let source: StreamSource let statsItems: [StatsItem] - init(source: StreamSource, streamStatistics: StreamStatistics) { + init( + source: StreamSource, + streamStatistics: StreamStatistics, + layers: [MCRTSRemoteTrackLayer], + projectedTimeStamp: Double? + ) { self.source = source - statsItems = Self.makeStatsList(from: source, streamStatistics: streamStatistics) + statsItems = Self.makeStatsList( + from: source, + streamStatistics: streamStatistics, + layers: layers, + projectedTimeStamp: projectedTimeStamp + ) } } // MARK: Stats report parsing private extension StatisticsViewModel { - // swiftlint:disable function_body_length - static func makeStatsList(from source: StreamSource, streamStatistics: StreamStatistics) -> [StatsItem] { + // swiftlint:disable function_body_length cyclomatic_complexity + static func makeStatsList( + from source: StreamSource, + streamStatistics: StreamStatistics, + layers: [MCRTSRemoteTrackLayer], + projectedTimeStamp: Double? + ) -> [StatsItem] { var result = [StatsItem]() guard let videoStatsInboundRtp = streamStatistics.videoStatsInboundRtpList.first(where: { $0.mid == source.videoTrack.currentMID }) else { return [] } - let audioStatsInboundRtp = streamStatistics.audioStatsInboundRtpList.first(where: { $0.mid == source.videoTrack.currentMID }) + let audioStatsInboundRtp = streamStatistics.audioStatsInboundRtpList.first if let streamViewId = streamStatistics.streamViewId { result.append( @@ -308,13 +324,15 @@ private extension StatisticsViewModel { ) ) - let totalTime = videoStatsInboundRtp.totalTime - result.append( - StatsItem( - key: String(localized: "stream.stats.total-stream-time.label"), - value: elapsedTimeString(totalTime / 1000) + if let projectedTimeStamp { + let totalTime = videoStatsInboundRtp.timestamp - projectedTimeStamp + result.append( + StatsItem( + key: String(localized: "stream.stats.total-stream-time.label"), + value: elapsedTimeString(totalTime / 1000) + ) ) - ) + } let audioCodec = audioStatsInboundRtp?.codecName let videoCodec = videoStatsInboundRtp.codecName @@ -332,18 +350,64 @@ private extension StatisticsViewModel { ) } - let incomingBitrate = videoStatsInboundRtp.incomingBitrate - result.append( - StatsItem( - key: String(localized: "stream.stats.incoming-bitrate.label"), - value: formatBitRate(bitRate: Int(incomingBitrate)) + if let projectedTimeStamp { + let bitsReceived = videoStatsInboundRtp.bytesReceived * 8 + let totalTimeInSeconds = (videoStatsInboundRtp.timestamp - projectedTimeStamp) / 1000 + let incomingBitRate = Double(bitsReceived) / totalTimeInSeconds + + result.append( + StatsItem( + key: String(localized: "stream.stats.incoming-bitrate.label"), + value: formatBitRate(bitRate: incomingBitRate) + ) ) - ) + } + + if let selectedLayer = layers.first(where: { + Int(videoStatsInboundRtp.frameWidth) == ($0.resolution?.width ?? 0) + && Int(videoStatsInboundRtp.frameHeight) == ($0.resolution?.height ?? 0) + }) { + if let targetBitrate = selectedLayer.targetBitrate { + result.append( + StatsItem( + key: String(localized: "stream.stats.target-bitrate.label"), + value: Self.formatBitRate(bitRate: targetBitrate.doubleValue) + ) + ) + } else { + result.append( + StatsItem( + key: String(localized: "stream.stats.target-bitrate.label"), + value: "N/A" + ) + ) + } + + result.append( + StatsItem( + key: String(localized: "stream.stats.outgoing-bitrate.label"), + value: Self.formatBitRate(bitRate: Double(selectedLayer.bitrate)) + ) + ) + } else { + result.append( + StatsItem( + key: String(localized: "stream.stats.target-bitrate.label"), + value: "N/A" + ) + ) + + result.append( + StatsItem( + key: String(localized: "stream.stats.outgoing-bitrate.label"), + value: "N/A" + ) + ) + } return result } - - // swiftlint:enable function_body_length + // swiftlint:enable function_body_length cyclomatic_complexity static func dateString(_ timestamp: Double) -> String { let dateFormatter = DateFormatter() @@ -369,9 +433,14 @@ private extension StatisticsViewModel { return "\(formatNumber(input: bytes))B" } - static func formatBitRate(bitRate: Int) -> String { - let value = formatNumber(input: bitRate).lowercased() - return "\(value)bps" + static func formatBitRate(bitRate: Double) -> String { + if bitRate < KILOBITS { + "\(bitRate) bps" + } else if bitRate >= KILOBITS && bitRate < MEGABITS { + "\((bitRate / KILOBITS).rounded(toPlaces: 4)) Kbps" + } else { + "\((bitRate / MEGABITS).rounded(toPlaces: 4)) Mbps" + } } static func formatNumber(input: Int) -> String { @@ -382,3 +451,14 @@ private extension StatisticsViewModel { private let KILOBYTES = 1024 private let MEGABYTES = KILOBYTES * KILOBYTES + +private let KILOBITS: Double = 1000 +private let MEGABITS = KILOBITS * KILOBITS + +private extension Double { + /// Rounds the double to decimal places value + func rounded(toPlaces places: Int) -> Double { + let divisor = pow(10.0, Double(places)) + return (self * divisor).rounded() / divisor + } +} diff --git a/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift b/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift index 269b3f3b..07572a18 100644 --- a/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift +++ b/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift @@ -34,7 +34,7 @@ struct StreamingView: View { BackgroundContainerView { ZStack { switch viewModel.state { - case let .streaming(source: source): + case let .streaming(source: source, _): VideoView(renderer: viewModel.rendererRegistry.acceleratedRenderer(for: source)) .overlay(alignment: .bottomTrailing) { SettingsButton { @@ -45,8 +45,21 @@ struct StreamingView: View { .padding() } .overlay(alignment: .bottomLeading) { - if showStatsView, let streamStatistics = viewModel.streamStatistics { - StatisticsView(source: source, streamStatistics: streamStatistics) + if showStatsView, let streamStatistics = viewModel.streamStatistics, + let mid = source.videoTrack.currentMID { + StatisticsView( + source: source, + streamStatistics: streamStatistics, + layers: viewModel.videoQualityList.compactMap { + switch $0 { + case .auto: + return nil + case let .quality(layer): + return layer + } + }, + projectedTimeStamp: viewModel.projectedTimeStampForMids[mid] + ) } } .overlay(alignment: .trailing) { diff --git a/rts-viewer-tvos/RTSViewer/StreamingView/StreamingViewModel.swift b/rts-viewer-tvos/RTSViewer/StreamingView/StreamingViewModel.swift index 339e7303..144605ef 100644 --- a/rts-viewer-tvos/RTSViewer/StreamingView/StreamingViewModel.swift +++ b/rts-viewer-tvos/RTSViewer/StreamingView/StreamingViewModel.swift @@ -26,10 +26,12 @@ final class StreamingViewModel: ObservableObject { private var isWebsocketConnected: Bool = false private var subscriptions: [AnyCancellable] = [] + private var projectedMids: Set = [] + private let serialTasks = SerialTasks() enum ViewState: Equatable { case disconnected - case streaming(source: StreamSource) + case streaming(source: StreamSource, playingAudio: Bool) case noNetwork(title: String) case streamNotPublished(title: String, subtitle: String, source: StreamSource?) case otherError(message: String) @@ -47,7 +49,7 @@ final class StreamingViewModel: ObservableObject { if !videoQualityList.contains(where: { $0.encodingId == selectedVideoQuality.encodingId }) { Self.logger.debug("♼ Reset layer to `auto`") switch state { - case let .streaming(source: source): + case let .streaming(source: source, _): select(videoQuality: .auto, for: source) default: break @@ -57,6 +59,7 @@ final class StreamingViewModel: ObservableObject { } @Published private(set) var selectedVideoQuality: VideoQuality = .auto @Published private(set) var streamStatistics: StreamStatistics? + @Published private(set) var projectedTimeStampForMids: [String: Double] = [:] let subscriptionManager: SubscriptionManager let rendererRegistry: RendererRegistry @@ -115,6 +118,7 @@ final class StreamingViewModel: ObservableObject { case let .quality(underlyingLayer): try await source.videoTrack.enable(renderer: renderer.underlyingRenderer, layer: MCRTSRemoteVideoTrackLayer(layer: underlyingLayer), promote: true) } + self.storeProjectedMid(for: source) } catch { Self.logger.debug("🎰 Select video quality error \(error.localizedDescription)") } @@ -144,22 +148,6 @@ final class StreamingViewModel: ObservableObject { return } - switch self.state { - case let .streaming(source: previousSource): - Self.logger.debug("🎰 Disabling previous source \(previousSource.sourceId)") - if previousSource.audioTrack?.isActive == true { - try await previousSource.audioTrack?.disable() - } - if previousSource.videoTrack.isActive { - try await previousSource.videoTrack.disable() - } - self.clearLayerInformation() - default: - break - } - - Self.logger.debug("🎰 Picked source \(videoSource.sourceId)") - Task(priority: .userInitiated) { guard !Task.isCancelled else { return } @@ -167,20 +155,52 @@ final class StreamingViewModel: ObservableObject { } Task(priority: .high) { - guard !Task.isCancelled, videoSource.videoTrack.isActive else { return } - - let renderer = self.rendererRegistry.acceleratedRenderer(for: videoSource) - try await videoSource.videoTrack.enable(renderer: renderer.underlyingRenderer, promote: true) - Self.logger.debug("🎰 Picked source \(videoSource.sourceId) for video") - - if let audioTrack = videoSource.audioTrack, audioTrack.isActive { - Self.logger.debug("🎰 Picked source \(videoSource.sourceId) for audio") - // Enable new audio track - try await audioTrack.enable() + guard + !Task.isCancelled, + videoSource.videoTrack.isActive + else { + return } - await MainActor.run { - self.state = .streaming(source: videoSource) + try await self.serialTasks.enqueue { + switch await self.state { + case let .streaming(source: currentSource, playingAudio: isPlayingAudio): + // No-action needed, already viewing stream + Self.logger.debug("🎰 Already viewing source \(currentSource.sourceId)") + if !isPlayingAudio { + if let audioTrack = videoSource.audioTrack, audioTrack.isActive { + Self.logger.debug("🎰 Picked source \(videoSource.sourceId) for audio") + // Enable new audio track + try await audioTrack.enable() + await MainActor.run { + self.state = .streaming(source: videoSource, playingAudio: true) + } + } + } + default: + Self.logger.debug("🎰 Picked source \(videoSource.sourceId)") + + let renderer = await MainActor.run { + self.rendererRegistry.acceleratedRenderer(for: videoSource) + } + try await videoSource.videoTrack.enable(renderer: renderer.underlyingRenderer, promote: true) + Self.logger.debug("🎰 Picked source \(videoSource.sourceId) for video") + await self.storeProjectedMid(for: videoSource) + + let isPlayingAudio: Bool + if let audioTrack = videoSource.audioTrack, audioTrack.isActive { + Self.logger.debug("🎰 Picked source \(videoSource.sourceId) for audio") + // Enable new audio track + try await audioTrack.enable() + isPlayingAudio = true + } else { + isPlayingAudio = false + } + + await MainActor.run { + self.state = .streaming(source: videoSource, playingAudio: isPlayingAudio) + } + } } } @@ -224,7 +244,7 @@ final class StreamingViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { websocketState in switch websocketState { - case .CONNECTED: + case .connected: self.isWebsocketConnected = true default: break @@ -255,12 +275,11 @@ final class StreamingViewModel: ObservableObject { // MARK: Track lifecycle events -extension StreamingViewModel { +private extension StreamingViewModel { func observeLayerEvents(for source: StreamSource) async { - if layersEventsObservationDictionary[source.sourceId] != nil { - layersEventsObservationDictionary[source.sourceId]?.cancel() - layersEventsObservationDictionary[source.sourceId] = nil + guard layersEventsObservationDictionary[source.sourceId] == nil else { + return } Self.logger.debug("♼ Registering layer events for \(source.sourceId)") @@ -293,6 +312,8 @@ extension StreamingViewModel { reconnectionTimer = nil clearLayerInformation() streamStatistics = nil + projectedTimeStampForMids.removeAll() + projectedMids.removeAll() } func clearLayerInformation() { @@ -312,10 +333,34 @@ extension StreamingViewModel { .sink { statistics in guard let statistics else { return } Task { + self.saveProjectedTimeStamp(stats: statistics) self.streamStatistics = statistics } } .store(in: &subscriptions) } } + + func saveProjectedTimeStamp(stats: StreamStatistics) { + stats.videoStatsInboundRtpList.forEach { + if let mid = $0.mid, projectedMids.contains(mid), + projectedTimeStampForMids[mid] == nil { + projectedTimeStampForMids[mid] = $0.timestamp + } + } + } + + func storeProjectedMid(for source: StreamSource) { + guard let mid = source.videoTrack.currentMID else { + return + } + projectedMids.insert(mid) + } + + func removeProjectedMid(for source: StreamSource) { + guard let mid = source.videoTrack.currentMID else { + return + } + projectedMids.remove(mid) + } } diff --git a/rts-viewer-tvos/RTSViewer/Utils/SerialTasks.swift b/rts-viewer-tvos/RTSViewer/Utils/SerialTasks.swift new file mode 100644 index 00000000..9031ba28 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Utils/SerialTasks.swift @@ -0,0 +1,40 @@ +// +// SerialTasks.swift +// + +import Foundation + +actor SerialTasks { + private var isRunning: Bool = false + private var queue = [CheckedContinuation]() + + deinit { + for continuation in queue { + continuation.resume(throwing: CancellationError()) + } + } + + public func enqueue(operation: @escaping @Sendable () async throws -> T) async throws -> T { + try Task.checkCancellation() + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + queue.append(continuation) + tryRunEnqueued() + } + + defer { + isRunning = false + tryRunEnqueued() + } + try Task.checkCancellation() + return try await operation() + } + + private func tryRunEnqueued() { + guard !queue.isEmpty, !isRunning else { return } + + isRunning = true + let continuation = queue.removeFirst() + continuation.resume() + } +}