diff --git a/.DS_Store b/.DS_Store index 63cd999d..b11594ae 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/rts-viewer-tvos/.DS_Store b/rts-viewer-tvos/.DS_Store index 615de75c..8d6c84fa 100644 Binary files a/rts-viewer-tvos/.DS_Store and b/rts-viewer-tvos/.DS_Store differ diff --git a/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj b/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj index b78e14b6..80d81129 100644 --- a/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj +++ b/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj @@ -14,12 +14,26 @@ 6D61A9FC299DBC30004CAF9E /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61A9FA299DBC30004CAF9E /* ErrorView.swift */; }; 6D61AA07299F51AC004CAF9E /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61AA06299F51AC004CAF9E /* VideoView.swift */; }; 6D6382B92977BCAE00DF4DA7 /* StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6382B72977BCAE00DF4DA7 /* StatisticsView.swift */; }; + 89C391C02C98C8B600861FD5 /* ChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391BF2C98C8B600861FD5 /* ChannelView.swift */; }; + 89C391C22C98C9A900861FD5 /* ChannelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391C12C98C9A900861FD5 /* ChannelViewModel.swift */; }; + 89C391C42C98C9D700861FD5 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391C32C98C9D700861FD5 /* Channel.swift */; }; + 89C391C72C98CA2E00861FD5 /* ChannelGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391C62C98CA2E00861FD5 /* ChannelGridView.swift */; }; + 89C391C92C98CA3D00861FD5 /* ChannelGridViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391C82C98CA3D00861FD5 /* ChannelGridViewModel.swift */; }; + 89C391CC2C9A0A5C00861FD5 /* ChannelDetailInputBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391CB2C9A0A5C00861FD5 /* ChannelDetailInputBox.swift */; }; + 89C391CE2C9A0A7900861FD5 /* ChannelDetailInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391CD2C9A0A7900861FD5 /* ChannelDetailInputViewModel.swift */; }; + 89C391D02C9A0D1700861FD5 /* StreamDetailInputBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391CF2C9A0D1700861FD5 /* StreamDetailInputBox.swift */; }; + 89C391D32C9B78A800861FD5 /* StreamDetailInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391D22C9B78A800861FD5 /* StreamDetailInputViewModel.swift */; }; + 89C391D72C9C963A00861FD5 /* VideoTracksManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391D62C9C963A00861FD5 /* VideoTracksManager.swift */; }; + 89C391D92C9C9EF500861FD5 /* String+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391D82C9C9EF500861FD5 /* String+Error.swift */; }; + 89C391DC2C9CA94800861FD5 /* VideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391DB2C9CA94800861FD5 /* VideoRendererView.swift */; }; + 89C391DE2C9CA96600861FD5 /* VideoRendererViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391DD2C9CA96600861FD5 /* VideoRendererViewModel.swift */; }; + 89C391E02C9CB5A600861FD5 /* SourceId+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391DF2C9CB5A600861FD5 /* SourceId+Display.swift */; }; B696B5332987790B00831FFF /* PersistentSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B696B5312987790B00831FFF /* PersistentSettings.swift */; }; E80E2BCB2C1010BE001733EA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E80E2BC82C1010BE001733EA /* Assets.xcassets */; }; E80E2BCC2C1010BE001733EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E80E2BC92C1010BE001733EA /* Localizable.strings */; }; E825CC222C2D0FF8009D878B /* RTSCore in Frameworks */ = {isa = PBXBuildFile; productRef = E825CC212C2D0FF8009D878B /* RTSCore */; }; E82A0C13296CEA0F007214B8 /* DolbyIOUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = E82A0C12296CEA0F007214B8 /* DolbyIOUIKit */; }; - E82A0C1E296D0F04007214B8 /* StreamDetailInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82A0C1A296D0F04007214B8 /* StreamDetailInputView.swift */; }; + E82A0C1E296D0F04007214B8 /* LandingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82A0C1A296D0F04007214B8 /* LandingView.swift */; }; E83CDA3A2A10917A008690FD /* FooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E83CDA2F2A10917A008690FD /* FooterView.swift */; }; E83CDA3D2A10917A008690FD /* NavigationHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E83CDA312A10917A008690FD /* NavigationHeaderView.swift */; }; E83CDA492A10917A008690FD /* BackgroundContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E83CDA382A10917A008690FD /* BackgroundContainerView.swift */; }; @@ -49,7 +63,7 @@ E8C776D72C2BEA5F002DE392 /* DolbyIOUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = E8C776D62C2BEA5F002DE392 /* DolbyIOUIKit */; }; E8C776D92C2BEEA5002DE392 /* VideoQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C776D82C2BEEA5002DE392 /* VideoQuality.swift */; }; E8D1947C29717D410080C4E0 /* RecentStreamsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D1947A29717D410080C4E0 /* RecentStreamsView.swift */; }; - E8D5AE59299068E20019C132 /* StreamDetailInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D5AE57299068E20019C132 /* StreamDetailInputViewModel.swift */; }; + E8D5AE59299068E20019C132 /* LandingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D5AE57299068E20019C132 /* LandingViewModel.swift */; }; E8F2EE2829932A9100AF0471 /* MockPersistentSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F2EE2729932A9100AF0471 /* MockPersistentSettings.swift */; }; /* End PBXBuildFile section */ @@ -95,12 +109,26 @@ 6D61A9FA299DBC30004CAF9E /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 6D61AA06299F51AC004CAF9E /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; 6D6382B72977BCAE00DF4DA7 /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.swift; sourceTree = ""; }; + 89C391BF2C98C8B600861FD5 /* ChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelView.swift; sourceTree = ""; }; + 89C391C12C98C9A900861FD5 /* ChannelViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelViewModel.swift; sourceTree = ""; }; + 89C391C32C98C9D700861FD5 /* Channel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = ""; }; + 89C391C62C98CA2E00861FD5 /* ChannelGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelGridView.swift; sourceTree = ""; }; + 89C391C82C98CA3D00861FD5 /* ChannelGridViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelGridViewModel.swift; sourceTree = ""; }; + 89C391CB2C9A0A5C00861FD5 /* ChannelDetailInputBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailInputBox.swift; sourceTree = ""; }; + 89C391CD2C9A0A7900861FD5 /* ChannelDetailInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailInputViewModel.swift; sourceTree = ""; }; + 89C391CF2C9A0D1700861FD5 /* StreamDetailInputBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDetailInputBox.swift; sourceTree = ""; }; + 89C391D22C9B78A800861FD5 /* StreamDetailInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDetailInputViewModel.swift; sourceTree = ""; }; + 89C391D62C9C963A00861FD5 /* VideoTracksManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoTracksManager.swift; sourceTree = ""; }; + 89C391D82C9C9EF500861FD5 /* String+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Error.swift"; sourceTree = ""; }; + 89C391DB2C9CA94800861FD5 /* VideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRendererView.swift; sourceTree = ""; }; + 89C391DD2C9CA96600861FD5 /* VideoRendererViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRendererViewModel.swift; sourceTree = ""; }; + 89C391DF2C9CB5A600861FD5 /* SourceId+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceId+Display.swift"; sourceTree = ""; }; B696B5312987790B00831FFF /* PersistentSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentSettings.swift; sourceTree = ""; }; E80E2BC82C1010BE001733EA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E80E2BC92C1010BE001733EA /* Localizable.strings */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; E80E2BCA2C1010BE001733EA /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E81D7C74296FAEE400856B89 /* IDETemplateMacros.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; name = IDETemplateMacros.plist; path = RTSViewer.xcworkspace/xcshareddata/IDETemplateMacros.plist; sourceTree = SOURCE_ROOT; }; - E82A0C1A296D0F04007214B8 /* StreamDetailInputView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StreamDetailInputView.swift; sourceTree = ""; }; + E82A0C1A296D0F04007214B8 /* LandingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LandingView.swift; sourceTree = ""; }; E83CDA2F2A10917A008690FD /* FooterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FooterView.swift; sourceTree = ""; }; E83CDA312A10917A008690FD /* NavigationHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationHeaderView.swift; sourceTree = ""; }; E83CDA382A10917A008690FD /* BackgroundContainerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundContainerView.swift; sourceTree = ""; }; @@ -132,7 +160,7 @@ E8C47C7029778BD00026E877 /* RTSViewer.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = RTSViewer.xcdatamodel; sourceTree = ""; }; E8C776D82C2BEEA5002DE392 /* VideoQuality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoQuality.swift; sourceTree = ""; }; E8D1947A29717D410080C4E0 /* RecentStreamsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentStreamsView.swift; sourceTree = ""; }; - E8D5AE57299068E20019C132 /* StreamDetailInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDetailInputViewModel.swift; sourceTree = ""; }; + E8D5AE57299068E20019C132 /* LandingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingViewModel.swift; sourceTree = ""; }; E8F2EE2729932A9100AF0471 /* MockPersistentSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPersistentSettings.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -182,6 +210,8 @@ 3FE90C1A26B2AF4200B206A3 /* RTSViewer */ = { isa = PBXGroup; children = ( + 89C391DA2C9CA93400861FD5 /* VideoRenderView */, + 89C391D52C9C962900861FD5 /* Managers */, 3FE90C1D26B2AF4200B206A3 /* ContentView.swift */, E81D7C74296FAEE400856B89 /* IDETemplateMacros.plist */, E8752B4C298C75A4002D5C2B /* Models */, @@ -192,8 +222,12 @@ E80E2BC72C1010BE001733EA /* Resources */, E83CDA2D2A10917A008690FD /* ReusableViews */, 3FE90C1B26B2AF4200B206A3 /* RTSViewer.swift */, + 89C391D12C9B733600861FD5 /* LandingView */, E82A0C19296D0F04007214B8 /* StreamDetailInputView */, + 89C391CA2C9A0A4300861FD5 /* ChannelDetailInputView */, E844ABEA296DA4C60067B78C /* StreamingView */, + 89C391BE2C98C89400861FD5 /* ChannelView */, + 89C391C52C98CA1E00861FD5 /* ChannelGridView */, E82FC5042977CF2A0050777F /* Utils */, ); path = RTSViewer; @@ -207,6 +241,59 @@ path = "Preview Content"; sourceTree = ""; }; + 89C391BE2C98C89400861FD5 /* ChannelView */ = { + isa = PBXGroup; + children = ( + 89C391BF2C98C8B600861FD5 /* ChannelView.swift */, + 89C391C12C98C9A900861FD5 /* ChannelViewModel.swift */, + ); + path = ChannelView; + sourceTree = ""; + }; + 89C391C52C98CA1E00861FD5 /* ChannelGridView */ = { + isa = PBXGroup; + children = ( + 89C391C62C98CA2E00861FD5 /* ChannelGridView.swift */, + 89C391C82C98CA3D00861FD5 /* ChannelGridViewModel.swift */, + ); + path = ChannelGridView; + sourceTree = ""; + }; + 89C391CA2C9A0A4300861FD5 /* ChannelDetailInputView */ = { + isa = PBXGroup; + children = ( + 89C391CB2C9A0A5C00861FD5 /* ChannelDetailInputBox.swift */, + 89C391CD2C9A0A7900861FD5 /* ChannelDetailInputViewModel.swift */, + ); + path = ChannelDetailInputView; + sourceTree = ""; + }; + 89C391D12C9B733600861FD5 /* LandingView */ = { + isa = PBXGroup; + children = ( + E82A0C1A296D0F04007214B8 /* LandingView.swift */, + E8D5AE57299068E20019C132 /* LandingViewModel.swift */, + ); + path = LandingView; + sourceTree = ""; + }; + 89C391D52C9C962900861FD5 /* Managers */ = { + isa = PBXGroup; + children = ( + 89C391D62C9C963A00861FD5 /* VideoTracksManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; + 89C391DA2C9CA93400861FD5 /* VideoRenderView */ = { + isa = PBXGroup; + children = ( + 89C391DB2C9CA94800861FD5 /* VideoRendererView.swift */, + 89C391DD2C9CA96600861FD5 /* VideoRendererViewModel.swift */, + ); + path = VideoRenderView; + sourceTree = ""; + }; E80E2BC72C1010BE001733EA /* Resources */ = { isa = PBXGroup; children = ( @@ -220,8 +307,8 @@ E82A0C19296D0F04007214B8 /* StreamDetailInputView */ = { isa = PBXGroup; children = ( - E8D5AE57299068E20019C132 /* StreamDetailInputViewModel.swift */, - E82A0C1A296D0F04007214B8 /* StreamDetailInputView.swift */, + 89C391CF2C9A0D1700861FD5 /* StreamDetailInputBox.swift */, + 89C391D22C9B78A800861FD5 /* StreamDetailInputViewModel.swift */, ); path = StreamDetailInputView; sourceTree = ""; @@ -232,6 +319,8 @@ E89D288F2C747AFE002254AB /* SerialTasks.swift */, E83CDA4F2A1092A3008690FD /* ImageAsset.swift */, E8752B50298C7D02002D5C2B /* DateProvider.swift */, + 89C391D82C9C9EF500861FD5 /* String+Error.swift */, + 89C391DF2C9CB5A600861FD5 /* SourceId+Display.swift */, ); path = Utils; sourceTree = ""; @@ -342,6 +431,7 @@ children = ( E8752B4D298C75BD002D5C2B /* StreamDetail.swift */, E8C776D82C2BEEA5002DE392 /* VideoQuality.swift */, + 89C391C32C98C9D700861FD5 /* Channel.swift */, ); path = Models; sourceTree = ""; @@ -564,10 +654,13 @@ buildActionMask = 2147483647; files = ( E83CDA512A1092A3008690FD /* ImageAsset.swift in Sources */, - E8D5AE59299068E20019C132 /* StreamDetailInputViewModel.swift in Sources */, + E8D5AE59299068E20019C132 /* LandingViewModel.swift in Sources */, E83CDA3A2A10917A008690FD /* FooterView.swift in Sources */, + 89C391DC2C9CA94800861FD5 /* VideoRendererView.swift in Sources */, + 89C391C02C98C8B600861FD5 /* ChannelView.swift in Sources */, E8BA8E202991EF3C0043DEE1 /* SettingsView.swift in Sources */, 6D61A9FC299DBC30004CAF9E /* ErrorView.swift in Sources */, + 89C391C72C98CA2E00861FD5 /* ChannelGridView.swift in Sources */, E8B48E86297A2957000DC59A /* RecentStreamButton.swift in Sources */, E8752B52298C7D02002D5C2B /* DateProvider.swift in Sources */, E8D1947C29717D410080C4E0 /* RecentStreamsView.swift in Sources */, @@ -578,23 +671,34 @@ E83CDA3D2A10917A008690FD /* NavigationHeaderView.swift in Sources */, E8BA8E102991CB0E0043DEE1 /* StreamingViewModel.swift in Sources */, E8C776D92C2BEEA5002DE392 /* VideoQuality.swift in Sources */, + 89C391CC2C9A0A5C00861FD5 /* ChannelDetailInputBox.swift in Sources */, 631DD25826F1E18E0023D24A /* ContentView.swift in Sources */, E83F0F932C192D4B00F6FA6B /* SettingsViewModel.swift in Sources */, E83CDA492A10917A008690FD /* BackgroundContainerView.swift in Sources */, + 89C391D02C9A0D1700861FD5 /* StreamDetailInputBox.swift in Sources */, E89D28902C747AFE002254AB /* SerialTasks.swift in Sources */, + 89C391E02C9CB5A600861FD5 /* SourceId+Display.swift in Sources */, + 89C391C22C98C9A900861FD5 /* ChannelViewModel.swift in Sources */, + 89C391DE2C9CA96600861FD5 /* VideoRendererViewModel.swift in Sources */, + 89C391C42C98C9D700861FD5 /* Channel.swift in Sources */, 6D61AA07299F51AC004CAF9E /* VideoView.swift in Sources */, - E82A0C1E296D0F04007214B8 /* StreamDetailInputView.swift in Sources */, + E82A0C1E296D0F04007214B8 /* LandingView.swift in Sources */, E8752B4B298C72F7002D5C2B /* CoreDataManager.swift in Sources */, E8BA8E232991FE400043DEE1 /* StatisticsViewModel.swift in Sources */, E8C47C6E29778BAA0026E877 /* StreamDataManager.swift in Sources */, E8752B4F298C75BD002D5C2B /* StreamDetail.swift in Sources */, + 89C391D32C9B78A800861FD5 /* StreamDetailInputViewModel.swift in Sources */, E83F0F962C192ECF00F6FA6B /* LiveIndicatorView.swift in Sources */, + 89C391D72C9C963A00861FD5 /* VideoTracksManager.swift in Sources */, 631DD25926F1E18E0023D24A /* RTSViewer.swift in Sources */, E8BA8E192991E8B30043DEE1 /* SimulcastViewModel.swift in Sources */, + 89C391D92C9C9EF500861FD5 /* String+Error.swift in Sources */, E8C47C7229778BDB0026E877 /* RTSViewer.xcdatamodeld in Sources */, E8752B5A298C9FB6002D5C2B /* RecentStreamButtonViewModel.swift in Sources */, + 89C391CE2C9A0A7900861FD5 /* ChannelDetailInputViewModel.swift in Sources */, E83F0F982C19305800F6FA6B /* SettingsButton.swift in Sources */, B696B5332987790B00831FFF /* PersistentSettings.swift in Sources */, + 89C391C92C98CA3D00861FD5 /* ChannelGridViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/rts-viewer-tvos/RTSViewer/ChannelDetailInputView/ChannelDetailInputBox.swift b/rts-viewer-tvos/RTSViewer/ChannelDetailInputView/ChannelDetailInputBox.swift new file mode 100644 index 00000000..b66a7bd7 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/ChannelDetailInputView/ChannelDetailInputBox.swift @@ -0,0 +1,116 @@ +// +// ChannelDetailInputBox.swift +// + +import DolbyIOUIKit +import RTSCore +import SwiftUI + +struct ChannelDetailInputBox: View { + @ObservedObject var viewModel: ChannelDetailInputViewModel + + init(viewModel: ChannelDetailInputViewModel) { + self.viewModel = viewModel + } + + var body: some View { + GeometryReader { proxy in + VStack(spacing: Layout.spacing2x) { + Text( + text: "stream-detail-input.header.label", + fontAsset: .avenirNextDemiBold( + size: FontSize.body, + style: .body + ) + ) + + VStack(spacing: Layout.spacing1x) { + Text( + text: "channel-detail-input.start-a-channel.label", + mode: .secondary, + fontAsset: .avenirNextDemiBold( + size: FontSize.title3, + style: .title3 + ) + ) + + Text( + text: "channel-detail-input.subtitle.label", + fontAsset: .avenirNextRegular( + size: FontSize.caption2, + style: .caption2 + ) + ) + } + ScrollView { + channelInput(placeholderStream: "channel-detail-input.streamName.placeholder1.label", + placeholderAccount: "channel-detail-input.accountId.placeholder1.label", + channelName: "channel-detail-input.channel-1.label", + streamName: $viewModel.streamName1, + accountID: $viewModel.accountID1) + + channelInput(placeholderStream: "channel-detail-input.streamName.placeholder2.label", + placeholderAccount: "channel-detail-input.accountId.placeholder2.label", + channelName: "channel-detail-input.channel-2.label", + streamName: $viewModel.streamName2, + accountID: $viewModel.accountID2) + + channelInput(placeholderStream: "channel-detail-input.streamName.placeholder3.label", + placeholderAccount: "channel-detail-input.accountId.placeholder3.label", + channelName: "channel-detail-input.channel-3.label", + streamName: $viewModel.streamName3, + accountID: $viewModel.accountID3) + + channelInput(placeholderStream: "channel-detail-input.streamName.placeholder4.label", + placeholderAccount: "channel-detail-input.accountId.placeholder4.label", + channelName: "channel-detail-input.channel-4.label", + streamName: $viewModel.streamName4, + accountID: $viewModel.accountID4) + } + + Button( + action: { + viewModel.playButtonPressed() + }, + text: "stream-detail-input.play.button" + ) + + Spacer() + .frame(height: Layout.spacing8x) + } + .padding(.all, Layout.spacing5x) + .background(Color(uiColor: UIColor.Background.black)) + .cornerRadius(Layout.cornerRadius6x) + .frame(width: proxy.size.width / 3) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private func channelInput( + placeholderStream: LocalizedStringKey, + placeholderAccount: LocalizedStringKey, + channelName: LocalizedStringKey, + streamName: Binding, + accountID: Binding + ) -> some View { + VStack(alignment: .leading, spacing: Layout.spacing2x) { + Text(text: channelName, + font: .custom("AvenirNext-Bold", size: FontSize.caption1, relativeTo: .caption) + ) + .multilineTextAlignment(.leading) + .padding(.top, Layout.spacing3x) + + TextField(placeholderStream, text: streamName) + .font(.avenirNextRegular(withStyle: .caption, size: FontSize.caption1)) + + TextField(placeholderAccount, text: accountID) + .font(.avenirNextRegular(withStyle: .caption, size: FontSize.caption1)) + + } + } +} + +// #Preview { +// ChannelDetailInputBox(viewModel: ChannelDetailInputViewModel(isShowingChannelView: .constant(true), onPlayTapped: {})) +// } diff --git a/rts-viewer-tvos/RTSViewer/ChannelDetailInputView/ChannelDetailInputViewModel.swift b/rts-viewer-tvos/RTSViewer/ChannelDetailInputView/ChannelDetailInputViewModel.swift new file mode 100644 index 00000000..d3ab5eb1 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/ChannelDetailInputView/ChannelDetailInputViewModel.swift @@ -0,0 +1,81 @@ +// +// ChannelDetailInputViewModel.swift +// + +import Foundation +import RTSCore +import SwiftUI + +struct StreamPair { + let streamName: String + let accountID: String +} + +@MainActor +class ChannelDetailInputViewModel: ObservableObject { + @Binding private var isShowingChannelView: Bool + @Binding private var channels: [Channel]? + + @Published var streamName1: String = "game" + @Published var accountID1: String = "7csQUs" + @Published var streamName2: String = "multiview" + @Published var accountID2: String = "k9Mwad" + @Published var streamName3: String = "game" + @Published var accountID3: String = "7csQUs" + @Published var streamName4: String = "multiview" + @Published var accountID4: String = "k9Mwad" + + init( + channels: Binding<[Channel]?>, isShowingChannelView: Binding) { + self._channels = channels + self._isShowingChannelView = isShowingChannelView + } + + func playButtonPressed() { + var confirmedChannels = [Channel]() + let streamDetails = createStreamDetailArray() + for detail in streamDetails { + guard let channel = setupChannel(for: detail) else { return } + confirmedChannels.append(channel) + } + guard !confirmedChannels.isEmpty else { return } + channels = confirmedChannels + isShowingChannelView = true + } +} + +private extension ChannelDetailInputViewModel { + func setupChannel(for detail: StreamPair) -> Channel? { + guard !detail.streamName.isEmpty, !detail.accountID.isEmpty else { return nil } + let subscriptionManager = SubscriptionManager() + let videoTracksManager = VideoTracksManager(subscriptionManager: subscriptionManager) + return Channel(streamDetail: detail, + subscriptionManager: subscriptionManager, + videoTracksManager: videoTracksManager) + } + + func createStreamDetailArray() -> [StreamPair] { + var streamPairs = [StreamPair]() + if !streamName1.isEmpty, !accountID1.isEmpty { + let streamPair = StreamPair(streamName: streamName1, accountID: accountID1) + streamPairs.append(streamPair) + } + if !streamName2.isEmpty, !accountID2.isEmpty { + let streamPair = StreamPair(streamName: streamName2, accountID: accountID2) + streamPairs.append(streamPair) + } + if !streamName3.isEmpty, !accountID3.isEmpty { + let streamPair = StreamPair(streamName: streamName3, accountID: accountID3) + streamPairs.append(streamPair) + } + if !streamName4.isEmpty, !accountID4.isEmpty { + let streamPair = StreamPair(streamName: streamName4, accountID: accountID4) + streamPairs.append(streamPair) + } + return streamPairs + } + + func checkIfCredentialsAreValid(streamName: String, accountID: String) -> Bool { + return streamName.count > 0 && accountID.count > 0 + } +} diff --git a/rts-viewer-tvos/RTSViewer/ChannelGridView/ChannelGridView.swift b/rts-viewer-tvos/RTSViewer/ChannelGridView/ChannelGridView.swift new file mode 100644 index 00000000..72c7cce6 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/ChannelGridView/ChannelGridView.swift @@ -0,0 +1,66 @@ +// +// ChannelGridView.swift +// + +import DolbyIOUIKit +import MillicastSDK +import RTSCore +import SwiftUI + +struct ChannelGridView: View { + private let viewModel: ChannelGridViewModel + + static let numberOfColumns = 2 + + init(viewModel: ChannelGridViewModel) { + self.viewModel = viewModel + } + + var body: some View { + GeometryReader { proxy in + let screenSize = proxy.size + let tileWidth = screenSize.width / CGFloat(Self.numberOfColumns) + let columns = [GridItem](repeating: GridItem(.flexible(), spacing: Layout.spacing1x), count: Self.numberOfColumns) + + LazyVGrid(columns: columns, alignment: .leading) { + ForEach(viewModel.channels) { channel in + let source = channel.source + let preferredVideoQuality: VideoQuality = .auto + let displayLabel = source.sourceId.displayLabel + let viewId = "\(ChannelGridView.self).\(displayLabel)" + VideoRendererView(source: source, + isSelectedVideoSource: true, + isSelectedAudioSource: true, + showSourceLabel: false, + showAudioIndicator: false, + maxWidth: tileWidth, + maxHeight: .infinity, + accessibilityIdentifier: "ChannelGridViewVideoTile.\(source.sourceId.displayLabel)", + preferredVideoQuality: preferredVideoQuality, + subscriptionManager: channel.subscriptionManager, + videoTracksManager: channel.videoTracksManager) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + .onAppear { + ChannelGridViewModel.logger.debug("♼ Channel Grid view: Video view appear for \(source.sourceId)") + Task { + await channel.videoTracksManager.enableTrack(for: source, with: preferredVideoQuality, on: viewId) + } + } + .onDisappear { + ChannelGridViewModel.logger.debug("♼ Channel Grid view: Video view disappear for \(source.sourceId)") + Task { + await channel.videoTracksManager.disableTrack(for: source, on: viewId) + } + } + .id(source.id) + .id(channel.source.id) + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + } +} + +#Preview { + ChannelGridView(viewModel: ChannelGridViewModel(channels: [])) +} diff --git a/rts-viewer-tvos/RTSViewer/ChannelGridView/ChannelGridViewModel.swift b/rts-viewer-tvos/RTSViewer/ChannelGridView/ChannelGridViewModel.swift new file mode 100644 index 00000000..fc119a97 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/ChannelGridView/ChannelGridViewModel.swift @@ -0,0 +1,22 @@ +// +// ChannelGridViewModel.swift +// + +import Foundation +import MillicastSDK +import os +import SwiftUI + +@MainActor +final class ChannelGridViewModel: ObservableObject { + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: ChannelGridViewModel.self) + ) + + let channels: [SourcedChannel] + + init(channels: [SourcedChannel]) { + self.channels = channels + } +} diff --git a/rts-viewer-tvos/RTSViewer/ChannelView/ChannelView.swift b/rts-viewer-tvos/RTSViewer/ChannelView/ChannelView.swift new file mode 100644 index 00000000..e8d504d6 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/ChannelView/ChannelView.swift @@ -0,0 +1,58 @@ +// +// ChannelView.swift +// + + import DolbyIOUIKit + import RTSCore + import SwiftUI + + struct ChannelView: View { + @ObservedObject private var viewModel: ChannelViewModel + @ObservedObject private var themeManager = ThemeManager.shared + + private var theme: Theme { themeManager.theme } + + init(viewModel: ChannelViewModel) { + self.viewModel = viewModel + } + + var body: some View { + NavigationView { + ZStack { + switch viewModel.state { + case let .success(channels: channels): + let viewModel = ChannelGridViewModel(channels: channels) + ChannelGridView(viewModel: viewModel) + case .loading: + progressView + case let .error(title: title, subtitle: subtitle, showLiveIndicator: showLiveIndicator): + errorView(title: title, subtitle: subtitle, showLiveIndicator: showLiveIndicator) + } + } + } + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + viewModel.viewStreams() + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + viewModel.endStream() + } + } + + @ViewBuilder + private func errorView(title: String, subtitle: String?, showLiveIndicator: Bool) -> some View { + ErrorView(title: title, subtitle: subtitle) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var progressView: some View { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + #Preview { + ChannelView(viewModel: ChannelViewModel(channels: .constant([]), onClose: {})) + } diff --git a/rts-viewer-tvos/RTSViewer/ChannelView/ChannelViewModel.swift b/rts-viewer-tvos/RTSViewer/ChannelView/ChannelViewModel.swift new file mode 100644 index 00000000..8214513b --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/ChannelView/ChannelViewModel.swift @@ -0,0 +1,167 @@ +// +// 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: ChannelViewModel.self) + ) + + @Binding var channels: [Channel]? + @Published private(set) var state: State = .loading + + private let onClose: () -> Void + private let serialTasks = SerialTasks() + private var subscriptions: [AnyCancellable] = [] + private var reconnectionTimer: Timer? + private var isWebsocketConnected: Bool = false + + private var sourcedChannels: [SourcedChannel] = [] + + enum State { + case loading + case success(channels: [SourcedChannel]) + case error(title: String, subtitle: String?, showLiveIndicator: Bool) + } + + init(channels: Binding<[Channel]?>, onClose: @escaping () -> Void) { + self._channels = channels + self.onClose = onClose + 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 + ) + } + } + + 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() + } + onClose() + } + } + + 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 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 { + await channel.subscriptionManager.$state + .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() + } + } + } + } + } + .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/rts-viewer-tvos/RTSViewer/ContentView.swift b/rts-viewer-tvos/RTSViewer/ContentView.swift index f6cafada..ff706fbf 100644 --- a/rts-viewer-tvos/RTSViewer/ContentView.swift +++ b/rts-viewer-tvos/RTSViewer/ContentView.swift @@ -9,7 +9,7 @@ struct ContentView: View { var body: some View { NavigationView { - StreamDetailInputView() + LandingView() } .navigationViewStyle(StackNavigationViewStyle()) } diff --git a/rts-viewer-tvos/RTSViewer/LandingView/LandingView.swift b/rts-viewer-tvos/RTSViewer/LandingView/LandingView.swift new file mode 100644 index 00000000..9d5d1c22 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/LandingView/LandingView.swift @@ -0,0 +1,79 @@ +// +// LandingView.swift +// + +import DolbyIOUIKit +import RTSCore +import SwiftUI + +struct LandingView: View { + @ObservedObject private var viewModel = LandingViewModel() + + var body: some View { + BackgroundContainerView { + ZStack { + /* + NavigationLink - Adds an unnecessary padding across its containing view - + so Øscreen navigations are not visually rendered - but only used for programmatic navigation + - in this case - controlled by the Binded `Bool` value. + */ + + NavigationLink(destination: StreamingView(streamName: viewModel.streamName, accountID: viewModel.accountID), isActive: $viewModel.isShowingStreamingView) { + EmptyView() + } + .hidden() + + let channelViewModel = ChannelViewModel(channels: $viewModel.channels) { + viewModel.isShowingChannelView = false + } + NavigationLink(destination: ChannelView(viewModel: channelViewModel), isActive: $viewModel.isShowingChannelView) { + EmptyView() + } + .hidden() + + VStack { + Spacer() + TabView { + let streamDetailInputViewModel = StreamDetailInputViewModel(streamName: $viewModel.streamName, + accountID: $viewModel.accountID, + isShowingStreamingView: $viewModel.isShowingStreamingView, + isShowingRecentStreams: $viewModel.isShowingRecentStreams) + StreamDetailInputBox(viewModel: streamDetailInputViewModel) + .tabItem { Label("SingleView", systemImage: "tv") } + + let channelDetailInputViewModel = ChannelDetailInputViewModel(channels: $viewModel.channels, + isShowingChannelView: $viewModel.isShowingChannelView) + ChannelDetailInputBox(viewModel: channelDetailInputViewModel) + .tabItem { Label("MultiChannel", systemImage: "tv") } + } + + FooterView(text: "stream-detail-input.footnote.label") + .padding(.bottom, Layout.spacing3x) + } + } + } + .navigationHeaderView() + .navigationBarHidden(true) + .alert("stream-detail-input.credentials-error.label", isPresented: $viewModel.isShowingErrorAlert) {} + .alert("stream-detail-input.clear-streams.label", isPresented: $viewModel.isShowingClearStreamsAlert, actions: { + Button( + "stream-detail-input.clear-streams.alert.clear.button", + role: .destructive, + action: { + viewModel.clearAllStreams() + } + ) + Button( + "stream-detail-input.clear-streams.alert.cancel.button", + role: .cancel, + action: {} + ) + }) + } +} + +struct StreamDetailInputView_Previews: PreviewProvider { + static var previews: some View { + LandingView() + } +} diff --git a/rts-viewer-tvos/RTSViewer/LandingView/LandingViewModel.swift b/rts-viewer-tvos/RTSViewer/LandingView/LandingViewModel.swift new file mode 100644 index 00000000..f27d6a93 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/LandingView/LandingViewModel.swift @@ -0,0 +1,31 @@ +// +// LandingViewModel.swift +// + +import Combine +import Foundation +import RTSCore + +@MainActor +final class LandingViewModel: ObservableObject { + @Published var streamName: String = "" + @Published var accountID: String = "" + @Published var channels: [Channel]? + + @Published var isShowingStreamingView: Bool = false + @Published var isShowingChannelView: Bool = false + @Published var isShowingRecentStreams: Bool = false + @Published var isShowingErrorAlert = false + @Published var isShowingClearStreamsAlert = false + + private let streamDataManager: StreamDataManagerProtocol + + init(streamDataManager: StreamDataManagerProtocol = StreamDataManager.shared) { + self.streamDataManager = streamDataManager + } + + func clearAllStreams() { + streamDataManager.clearAllStreams() + } + +} diff --git a/rts-viewer-tvos/RTSViewer/Managers/VideoTracksManager.swift b/rts-viewer-tvos/RTSViewer/Managers/VideoTracksManager.swift new file mode 100644 index 00000000..7113cbb3 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Managers/VideoTracksManager.swift @@ -0,0 +1,395 @@ +// +// VideoTracksManager.swift +// + +import Combine +import Foundation +import MillicastSDK +import os +import RTSCore + +final actor VideoTracksManager { + typealias ViewID = String + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: VideoTracksManager.self) + ) + + private struct VideoQualityAndLayerPair: Equatable { + let videoQuality: VideoQuality + let layer: MCRTSRemoteTrackLayer? + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.videoQuality == rhs.videoQuality && lhs.layer?.encodingId == rhs.layer?.encodingId + } + } + + private var sourceToTasks: [SourceID: SerialTasks] = [:] + + // View's rendering a source + private var sourceToActiveViewsMapping: [SourceID: [ViewID]] = [:] + + // View's to requested video quality + private var viewToRequestedVideoQualityMapping: [ViewID: VideoQuality] = [:] + + // Selected layer information + private var sourceToSelectedVideoQualityAndLayerMapping: [SourceID: VideoQualityAndLayerPair] = [:] { + didSet { + let sourceToVideoQuality = sourceToSelectedVideoQualityAndLayerMapping + .mapValues { + $0.videoQuality + } + videoQualitySubject.send(sourceToVideoQuality) + } + } + + private let subscriptionManager: SubscriptionManager + private var layerEventsObservationDictionary: [SourceID: Task] = [:] + private let videoQualitySubject: CurrentValueSubject<[SourceID: VideoQuality], Never> = CurrentValueSubject([:]) + private let layersSubject: CurrentValueSubject<[SourceID: [MCRTSRemoteTrackLayer]], Never> = CurrentValueSubject([:]) + + let rendererRegistry: RendererRegistry + lazy var selectedVideoQualityPublisher = videoQualitySubject.eraseToAnyPublisher() + lazy var layersPublisher = layersSubject.eraseToAnyPublisher() + private(set) var projectedTimeStampForMids: [String: Double] = [:] + private(set) var sourceToSimulcastLayersMapping: [SourceID: [MCRTSRemoteTrackLayer]] = [:] { + didSet { + layersSubject.send(sourceToSimulcastLayersMapping) + } + } + + private var subscriptions: Set = [] + private var projectedMids: Set = [] + + init(subscriptionManager: SubscriptionManager, rendererRegistry: RendererRegistry = RendererRegistry()) { + self.rendererRegistry = rendererRegistry + self.subscriptionManager = subscriptionManager + observeStats() + } + + private nonisolated func observeStats() { + Task { [weak self] in + guard let self else { return } + let cancellable = await self.subscriptionManager.$streamStatistics + .receive(on: DispatchQueue.main) + .sink { [weak self] statistics in + guard let self, let stats = statistics else { return } + Task { + await self.saveProjectedTimeStamp(stats: stats) + } + } + await self.store(cancellable: cancellable) + } + } + + func observeLayerUpdates(for source: StreamSource) { + Task { [weak self] in + guard + let self, + await self.layerEventsObservationDictionary[source.sourceId] == nil + else { + return + } + let layerEventsObservationTask = Task { + for await layerEvent in source.videoTrack.layers() { + let simulcastLayers = layerEvent.layers() + await self.addSimulcastLayers(simulcastLayers, for: source) + } + } + + Self.logger.debug("♼ Registering layers events of \(source.sourceId)") + await self.addLayerEventsObservationTask(layerEventsObservationTask, for: source) + await layerEventsObservationTask.value + } + } + + func reset() { + sourceToTasks.removeAll() + layerEventsObservationDictionary.removeAll() + sourceToActiveViewsMapping.removeAll() + viewToRequestedVideoQualityMapping.removeAll() + sourceToSimulcastLayersMapping.removeAll() + sourceToSelectedVideoQualityAndLayerMapping.removeAll() + projectedMids.removeAll() + projectedTimeStampForMids.removeAll() + } + + func enableTrack(for source: StreamSource, with preferredVideoQuality: VideoQuality, on view: ViewID) async { + let sourceId = source.sourceId + Self.logger.debug("♼ Request to enable video track for source \(sourceId) with preferredVideoQuality \(preferredVideoQuality.displayText) from view \(view.description)") + + // If the view has already requested the same video quality before? if yes, exit + guard viewToRequestedVideoQualityMapping[view] != preferredVideoQuality else { + Self.logger.debug("♼ Exiting - View already presented for source \(sourceId) with preferredVideoQuality \(preferredVideoQuality.displayText)") + return + } + + let activeViewsForSource = sourceToActiveViewsMapping[sourceId] ?? [] + // Calculate the video quality to project from the requested list + // Note: Only one layer can be selected for a source at a given time + var videoQualitiesRequestedForSource = activeViewsForSource + .compactMap { viewToRequestedVideoQualityMapping[$0] } + videoQualitiesRequestedForSource.append(preferredVideoQuality) + let bestVideoQualityFromTheList = videoQualitiesRequestedForSource.bestVideoQualityFromTheRequestedList + let simulcastLayers = sourceToSimulcastLayersMapping[sourceId] + let layerToSelect = simulcastLayers.map { $0.matching(quality: bestVideoQualityFromTheList) } ?? nil + Self.logger.debug("♼ Source \(sourceId) has \(simulcastLayers?.count ?? 0) simulcast layers") + + let videoQualityToSelect: VideoQuality = layerToSelect == nil ? .auto : bestVideoQualityFromTheList + let newVideoQualityAndLayerPair = VideoQualityAndLayerPair(videoQuality: videoQualityToSelect, layer: layerToSelect) + + Self.logger.debug("♼ Add active view \(view.description) for source \(sourceId)") + // Update view's requested video quality + viewToRequestedVideoQualityMapping[view] = preferredVideoQuality + + // Add new view to the list of active views for that source + if var views = sourceToActiveViewsMapping[sourceId] { + views.append(view) + sourceToActiveViewsMapping[source.sourceId] = views + } else { + sourceToActiveViewsMapping[source.sourceId] = [view] + } + + do { + if let layerToSelect { + Self.logger.debug("♼ Simulcast layer - \(layerToSelect) for source \(sourceId)") + Self.logger.debug("♼ Selecting videoquality \(videoQualityToSelect.displayText) for source \(sourceId) on view \(view)") + sourceToSelectedVideoQualityAndLayerMapping[sourceId] = newVideoQualityAndLayerPair + + try await queueEnableTrack(for: source, layer: MCRTSRemoteVideoTrackLayer(layer: layerToSelect)) + } else { + Self.logger.debug("♼ No simulcast layer for source \(sourceId) matching \(bestVideoQualityFromTheList.displayText)") + Self.logger.debug("♼ Selecting videoquality 'Auto' for source \(sourceId) on view \(view)") + sourceToSelectedVideoQualityAndLayerMapping[sourceId] = newVideoQualityAndLayerPair + + try await queueEnableTrack(for: source) + } + } catch { + Self.logger.debug("♼🛑 Enabling video track threw error \(error.localizedDescription)") + } + } + + func disableTrack(for source: StreamSource, on view: ViewID) async { + let sourceId = source.sourceId + Self.logger.debug("♼ Request to disable video track for source \(sourceId) on view \(view.description)") + // Remove view from the list of active views for that source + guard var activeViews = sourceToActiveViewsMapping[sourceId], + activeViews.contains(where: { $0 == view }) + else { + Self.logger.debug("♼ \(view.description) is not in the list of active views, returning") + return + } + Self.logger.debug("♼ Remove view \(view.description) for source \(sourceId)") + activeViews.removeAll(where: { $0 == view }) + sourceToActiveViewsMapping[source.sourceId] = !activeViews.isEmpty ? activeViews : nil + + // Remove view from View to requested video quality mapping + viewToRequestedVideoQualityMapping[view] = nil + + if let activeViews = sourceToActiveViewsMapping[sourceId] { + // Calculate the video quality to project from the requested list + // Note: Only one projection can exist for a source at a given time + let videoQualitiesRequestedForSource = activeViews.compactMap { viewToRequestedVideoQualityMapping[$0] } + let bestVideoQualityFromRequested = videoQualitiesRequestedForSource.bestVideoQualityFromTheRequestedList + let simulcastLayers = sourceToSimulcastLayersMapping[sourceId] + let layerToSelect = simulcastLayers.map { $0.matching(quality: bestVideoQualityFromRequested) } ?? nil + + let selectedVideoQuality: VideoQuality = layerToSelect == nil ? .auto : bestVideoQualityFromRequested + let newVideoQualityAndLayerPair = VideoQualityAndLayerPair(videoQuality: selectedVideoQuality, layer: layerToSelect) + + do { + if let layerToSelect { + Self.logger.debug("♼ Has simulcast layer - \(layerToSelect) for source \(sourceId)") + Self.logger.debug("♼ Selecting videoquality \(selectedVideoQuality.displayText) for source \(sourceId); active view \(activeViews)") + sourceToSelectedVideoQualityAndLayerMapping[sourceId] = newVideoQualityAndLayerPair + try await queueEnableTrack(for: source, layer: MCRTSRemoteVideoTrackLayer(layer: layerToSelect)) + } else { + Self.logger.debug("♼ No simulcast layer for source \(sourceId) matching \(bestVideoQualityFromRequested.displayText)") + Self.logger.debug("♼ Selecting videoquality 'Auto' for source \(sourceId); active view \(activeViews)") + sourceToSelectedVideoQualityAndLayerMapping[sourceId] = newVideoQualityAndLayerPair + try await queueEnableTrack(for: source) + } + } catch { + Self.logger.debug("♼🛑 Enabling video track threw error \(error.localizedDescription)") + } + } else { + do { + Self.logger.debug("♼ Disable video track for source \(sourceId) as there are no active views") + + // Remove selected Video quality for source + sourceToSelectedVideoQualityAndLayerMapping[sourceId] = nil + + removeAllStoredData(for: source) + + try await queueDisableTrack(for: source) + } catch { + Self.logger.debug("♼🛑 Disabling video track threw error \(error.localizedDescription)") + } + } + } + + func queueEnableTrack(for source: StreamSource, layer: MCRTSRemoteVideoTrackLayer? = nil) async throws { + if sourceToTasks[source.sourceId] == nil { + sourceToTasks[source.sourceId] = SerialTasks() + } + guard let serialTasks = sourceToTasks[source.sourceId], + source.videoTrack.isActive + else { + return + } + + let renderer = rendererRegistry.sampleBufferRenderer(for: source).underlyingRenderer + try await serialTasks.enqueue { [weak self] in + Self.logger.debug("♼ Queue: Enabling track for source \(source.sourceId) on renderer \(ObjectIdentifier(renderer).debugDescription)") + guard + let self, + !Task.isCancelled, + source.videoTrack.isActive + else { + return + } + if let layer { + try await source.videoTrack.enable(renderer: renderer, layer: layer) + } else { + try await source.videoTrack.enable(renderer: renderer) + } + // Store mid + if let mid = source.videoTrack.currentMID { + await self.store(mid: mid) + } + Self.logger.debug("♼ Queue: Finished enabling track for source \(source.sourceId) on renderer \(ObjectIdentifier(renderer).debugDescription)") + } + } + + func queueDisableTrack(for source: StreamSource) async throws { + guard let serialTasks = sourceToTasks[source.sourceId] else { return } + try await serialTasks.enqueue { + guard !Task.isCancelled, source.videoTrack.isActive else { return } + Self.logger.debug("♼ Queue: Disabling track for source \(source.sourceId)") + try await source.videoTrack.disable() + // Remove mid + if let mid = source.videoTrack.currentMID { + await self.remove(mid: mid) + } + Self.logger.debug("♼ Queue: Finished disabling track for source \(source.sourceId)") + } + } +} + +// MARK: Helper functions + +private extension VideoTracksManager { + func store(cancellable: AnyCancellable) { + subscriptions.insert(cancellable) + } + + func store(mid: String) { + projectedMids.insert(mid) + } + + func remove(mid: String) { + projectedMids.remove(mid) + projectedTimeStampForMids.removeValue(forKey: mid) + } + + func saveProjectedTimeStamp(stats: StreamStatistics) { + stats.videoStatsInboundRtpList.forEach { + if let mid = $0.mid, projectedMids.contains(mid), + projectedTimeStampForMids[mid] == nil { + projectedTimeStampForMids[mid] = $0.timestamp + } + } + } + + func addSimulcastLayers(_ layers: [MCRTSRemoteTrackLayer], for source: StreamSource) async { + sourceToSimulcastLayersMapping[source.sourceId] = layers + Self.logger.debug("♼ Add layers \(layers.count) for \(source.sourceId)") + let sourceId = source.sourceId + + // Choose any active view to reenable the track + guard let activeViews = sourceToActiveViewsMapping[sourceId], + let anyActiveView = activeViews.first + else { + Self.logger.debug("♼ No active views for \(source.sourceId)") + return + } + + // Calculate the video quality to project from the requested list + // Note: Only one projection can exist for a source at a given time + let videoQualitiesRequestedForSource = activeViews + .compactMap { viewToRequestedVideoQualityMapping[$0] } + let bestVideoQualityFromRequested = videoQualitiesRequestedForSource.bestVideoQualityFromTheRequestedList + let layerToSelect = layers.matching(quality: bestVideoQualityFromRequested) + + let selectedVideoQuality: VideoQuality = layerToSelect == nil ? .auto : bestVideoQualityFromRequested + let newVideoQualityAndLayerPair = VideoQualityAndLayerPair(videoQuality: selectedVideoQuality, layer: layerToSelect) + + let currentVideoQualityAndLayerPair = sourceToSelectedVideoQualityAndLayerMapping[source.sourceId] + guard newVideoQualityAndLayerPair != currentVideoQualityAndLayerPair else { + // Currently selected video quality matches the newly calculated one, no action needed + Self.logger.debug("♼ Exiting - Currently selected videoquality \(selectedVideoQuality.displayText) for source \(sourceId) is already up to date") + return + } + + do { + if let layerToSelect { + Self.logger.debug("♼ Has simulcast layer - \(layerToSelect) for source \(sourceId)") + Self.logger.debug("♼ Selecting videoquality \(selectedVideoQuality.displayText) for source \(sourceId) on view \(anyActiveView)") + sourceToSelectedVideoQualityAndLayerMapping[sourceId] = newVideoQualityAndLayerPair + + try await queueEnableTrack(for: source, layer: MCRTSRemoteVideoTrackLayer(layer: layerToSelect)) + } else { + Self.logger.debug("♼ No simulcast layer for source \(sourceId) matching \(bestVideoQualityFromRequested.displayText)") + Self.logger.debug("♼ Selecting videoquality 'Auto' for source \(sourceId) on view \(anyActiveView)") + sourceToSelectedVideoQualityAndLayerMapping[sourceId] = newVideoQualityAndLayerPair + + try await queueEnableTrack(for: source) + } + } catch { + Self.logger.debug("♼🛑 Enabling video track threw error \(error.localizedDescription)") + } + } + + func removeAllStoredData(for source: StreamSource) { + let sourceId = source.sourceId + let activeViews = sourceToActiveViewsMapping[sourceId] + + activeViews?.forEach { viewToRequestedVideoQualityMapping[$0] = nil } + sourceToSimulcastLayersMapping[sourceId] = nil + sourceToActiveViewsMapping[sourceId] = nil + } +} + +// MARK: Helpers to manage `Event` Observations + +private extension VideoTracksManager { + func addLayerEventsObservationTask(_ task: Task, for source: StreamSource) { + layerEventsObservationDictionary[source.sourceId] = task + } +} + +private extension Array where Self.Element == VideoQuality { + var bestVideoQualityFromTheRequestedList: VideoQuality { + sorted(by: >).first ?? .auto + } +} + +private extension Array where Self.Element == MCRTSRemoteTrackLayer { + func matching(quality: VideoQuality) -> MCRTSRemoteTrackLayer? { + return switch quality { + case .auto: + nil + case let .quality(layer): + layer + } + } +} + +private extension Array { + var middle: Element? { + guard count != 0 else { return nil } + let middleIndex = (count > 1 ? count - 1 : count) / 2 + return self[middleIndex] + } +} diff --git a/rts-viewer-tvos/RTSViewer/Models/Channel.swift b/rts-viewer-tvos/RTSViewer/Models/Channel.swift new file mode 100644 index 00000000..90a301a7 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Models/Channel.swift @@ -0,0 +1,31 @@ +// +// Channel.swift +// + +import Foundation +import RTSCore + +struct Channel: Identifiable { + let id = UUID() + let streamDetail: StreamPair + let subscriptionManager: SubscriptionManager + let videoTracksManager: VideoTracksManager +} + +struct SourcedChannel: Identifiable { + let id: UUID + let streamDetail: StreamPair + 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, + subscriptionManager: channel.subscriptionManager, + videoTracksManager: channel.videoTracksManager, + source: source) + } +} diff --git a/rts-viewer-tvos/RTSViewer/Models/VideoQuality.swift b/rts-viewer-tvos/RTSViewer/Models/VideoQuality.swift index 7c0ac3c2..e298168e 100644 --- a/rts-viewer-tvos/RTSViewer/Models/VideoQuality.swift +++ b/rts-viewer-tvos/RTSViewer/Models/VideoQuality.swift @@ -5,16 +5,7 @@ import Foundation import MillicastSDK -enum VideoQuality: Identifiable { - var id: String { - switch self { - case .auto: - "Auto" - case let .quality(videoTrackLayer): - videoTrackLayer.encodingId - } - } - +enum VideoQuality: Identifiable, Equatable, Comparable { case auto case quality(MCRTSRemoteTrackLayer) @@ -24,6 +15,26 @@ enum VideoQuality: Identifiable { } extension VideoQuality { + static func < (lhs: VideoQuality, rhs: VideoQuality) -> Bool { + return switch (lhs, rhs) { + case(.quality, .auto): + false + case let (.quality(lhsQuality), .quality(rhsQuality)): + lhsQuality == rhsQuality + default: + true + } + } + + var id: String { + switch self { + case .auto: + "Auto" + case let .quality(videoTrackLayer): + videoTrackLayer.encodingId + } + } + var displayText: String { switch self { case .auto: @@ -57,7 +68,7 @@ extension VideoQuality { } var target: [String] = [] if let bitrate = layer.targetBitrate { - target.append("Bitrate: \(bitrate.intValue/1000) kbps") + target.append("Bitrate: \(bitrate.intValue / 1000) kbps") } if let resolution = layer.resolution { target.append("Resolution: \(resolution.width)x\(resolution.height)") diff --git a/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings b/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings index 338ab379..6a8f9b45 100644 --- a/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings +++ b/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings @@ -88,3 +88,22 @@ "stream.stats.total-stream-time.label" = "Total Stream Time"; "stream.stats.target-bitrate.label" = "Target Bitrate"; "stream.stats.outgoing-bitrate.label" = "Outgoing Bitrate"; + +/** 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"; +"channel-detail-input.subtitle.label" = "Enter your stream information to view channels"; +"channel-detail-input.streamName.placeholder1.label" = "Enter your first stream name"; +"channel-detail-input.accountId.placeholder1.label" = "Enter your first account ID"; +"channel-detail-input.streamName.placeholder2.label" = "Enter your second stream name"; +"channel-detail-input.accountId.placeholder2.label" = "Enter your second account ID"; +"channel-detail-input.streamName.placeholder3.label" = "Enter your third stream name"; +"channel-detail-input.accountId.placeholder3.label" = "Enter your third account ID"; +"channel-detail-input.streamName.placeholder4.label" = "Enter your fourth stream name"; +"channel-detail-input.accountId.placeholder4.label" = "Enter your fourth account ID"; + +"video-view.main.label" = "Main"; diff --git a/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputBox.swift b/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputBox.swift new file mode 100644 index 00000000..9568140c --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputBox.swift @@ -0,0 +1,103 @@ +// +// StreamDetailInputBox.swift +// + +import DolbyIOUIKit +import RTSCore +import SwiftUI + +struct StreamDetailInputBox: View { + @ObservedObject var viewModel: StreamDetailInputViewModel + + init(viewModel: StreamDetailInputViewModel) { + self.viewModel = viewModel + } + + var body: some View { + GeometryReader { proxy in + VStack(spacing: Layout.spacing2x) { + Text( + text: "stream-detail-input.header.label", + fontAsset: .avenirNextDemiBold( + size: FontSize.body, + style: .body + ) + ) + + VStack(spacing: Layout.spacing1x) { + Text( + text: "stream-detail-input.title.label", + mode: .secondary, + fontAsset: .avenirNextDemiBold( + size: FontSize.title3, + style: .title3 + ) + ) + + Text( + text: "stream-detail-input.subtitle.label", + fontAsset: .avenirNextRegular( + size: FontSize.caption2, + style: .caption2 + ) + ) + } + + VStack(spacing: Layout.spacing3x) { + TextField("stream-detail-input.streamName.placeholder.label", text: $viewModel.streamName) + .font(.avenirNextRegular(withStyle: .caption, size: FontSize.caption1)) + + TextField("stream-detail-input.accountId.placeholder.label", text: $viewModel.accountID) + .font(.avenirNextRegular(withStyle: .caption, size: FontSize.caption1)) + + if viewModel.hasSavedStreams { + Button( + action: { + viewModel.isShowingRecentStreams = true + }, + text: "stream-detail-input.recent-streams.button", + mode: .secondary + ) + } + + Button( + action: { + viewModel.playStream() + }, + text: "stream-detail-input.play.button" + ) + + if viewModel.hasSavedStreams { + HStack { + LinkButton( + action: { + viewModel.isShowingClearStreamsAlert = true + }, + text: "stream-detail-input.clear-stream-history.button", + fontAsset: .avenirNextBold(size: FontSize.caption2, style: .caption2) + ) + + Spacer() + } + } + } + Spacer() + .frame(height: Layout.spacing8x) + } + .sheet(isPresented: $viewModel.isShowingRecentStreams) { + RecentStreamsView( + streamName: $viewModel.streamName, + accountID: $viewModel.accountID, + isShowingRecentStreams: $viewModel.isShowingRecentStreams + ) { + viewModel.playStream() + } + } + .padding(.all, Layout.spacing5x) + .background(Color(uiColor: UIColor.Background.black)) + .cornerRadius(Layout.cornerRadius6x) + .frame(width: proxy.size.width / 3) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputView.swift b/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputView.swift deleted file mode 100644 index bfb93fd8..00000000 --- a/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputView.swift +++ /dev/null @@ -1,214 +0,0 @@ -// -// StreamDetailInputView.swift -// - -import DolbyIOUIKit -import SwiftUI -import RTSCore - -struct StreamDetailInputView: View { - - @State private var streamName: String = "" - @State private var accountID: String = "" - - @State private var isShowingStreamingView: Bool = false - @State private var isShowingRecentStreams: Bool = false - @State private var isShowingErrorAlert = false - @State private var isShowingClearStreamsAlert = false - - @StateObject private var viewModel: StreamDetailInputViewModel = .init() - - var body: some View { - BackgroundContainerView { - ZStack { - - /* - NavigationLink - Adds an unnecessary padding across its containing view - - so screen navigations are not visually rendered - but only used for programmatic navigation - - in this case - controlled by the Binded `Bool` value. - */ - - NavigationLink(destination: StreamingView(streamName: streamName, accountID: accountID), isActive: $isShowingStreamingView) { - EmptyView() - } - .hidden() - - VStack { - StreamDetailInputBox( - streamName: $streamName, - accountID: $accountID, - isShowingStreamingView: $isShowingStreamingView, - isShowingRecentStreams: $isShowingRecentStreams, - isShowingClearStreamsAlert: $isShowingClearStreamsAlert, - hasSavedStreams: viewModel.hasSavedStreams, - onPlayTapped: { - playStream() - } - ) - - Spacer() - FooterView(text: "stream-detail-input.footnote.label") - .padding(.bottom, Layout.spacing3x) - } - .sheet(isPresented: $isShowingRecentStreams) { - RecentStreamsView( - streamName: $streamName, - accountID: $accountID, - isShowingRecentStreams: $isShowingRecentStreams - ) { - playStream() - } - } - } - } - .navigationHeaderView() - .navigationBarHidden(true) - .alert("stream-detail-input.credentials-error.label", isPresented: $isShowingErrorAlert) { } - .alert("stream-detail-input.clear-streams.label", isPresented: $isShowingClearStreamsAlert, actions: { - Button( - "stream-detail-input.clear-streams.alert.clear.button", - role: .destructive, - action: { - viewModel.clearAllStreams() - } - ) - Button( - "stream-detail-input.clear-streams.alert.cancel.button", - role: .cancel, - action: {} - ) - }) - } - - private func playStream() { - Task { - guard viewModel.checkIfCredentialsAreValid(streamName: streamName, accountID: accountID) else { - isShowingErrorAlert = true - return - } - - viewModel.saveStream(streamName: streamName, accountID: accountID) - isShowingStreamingView = true - } - } -} - -private struct StreamDetailInputBox: View { - @Binding private var streamName: String - @Binding private var accountID: String - @Binding private var isShowingStreamingView: Bool - @Binding private var isShowingRecentStreams: Bool - @Binding private var isShowingClearStreamsAlert: Bool - private let hasSavedStreams: Bool - private let onPlayTapped: () -> Void - - init( - streamName: Binding, - accountID: Binding, - isShowingStreamingView: Binding, - isShowingRecentStreams: Binding, - isShowingClearStreamsAlert: Binding, - hasSavedStreams: Bool, - onPlayTapped: @escaping () -> Void - ) { - _streamName = streamName - _accountID = accountID - _isShowingStreamingView = isShowingStreamingView - _isShowingRecentStreams = isShowingRecentStreams - _isShowingClearStreamsAlert = isShowingClearStreamsAlert - self.hasSavedStreams = hasSavedStreams - self.onPlayTapped = onPlayTapped - } - - var body: some View { - GeometryReader { proxy in - VStack(spacing: Layout.spacing6x) { - Text( - text: "stream-detail-input.header.label", - fontAsset: .avenirNextDemiBold( - size: FontSize.body, - style: .body - ) - ) - - VStack(spacing: Layout.spacing1x) { - Text( - text: "stream-detail-input.title.label", - mode: .secondary, - fontAsset: .avenirNextDemiBold( - size: FontSize.title2, - style: .title2 - ) - ) - - Text( - text: "stream-detail-input.subtitle.label", - fontAsset: .avenirNextRegular( - size: FontSize.caption2, - style: .caption2 - ) - ) - } - - VStack(spacing: Layout.spacing3x) { - - TextField("stream-detail-input.streamName.placeholder.label", text: $streamName) - .onReceive(streamName.publisher) { _ in - streamName = String(streamName.prefix(64)) - } - .font(.avenirNextRegular(withStyle: .caption, size: FontSize.caption1)) - - TextField("stream-detail-input.accountId.placeholder.label", text: $accountID) - .onReceive(accountID.publisher) { _ in - accountID = String(accountID.prefix(64)) - } - .font(.avenirNextRegular(withStyle: .caption, size: FontSize.caption1)) - - if hasSavedStreams { - DolbyIOUIKit.Button( - action: { - isShowingRecentStreams = true - }, - text: "stream-detail-input.recent-streams.button", - mode: .secondary - ) - } - - Button( - action: { - onPlayTapped() - }, - text: "stream-detail-input.play.button" - ) - - if hasSavedStreams { - HStack { - LinkButton( - action: { - isShowingClearStreamsAlert = true - }, - text: "stream-detail-input.clear-stream-history.button", - fontAsset: .avenirNextBold(size: FontSize.caption2, style: .caption2) - ) - - Spacer() - } - } - } - Spacer() - .frame(height: Layout.spacing8x) - } - .padding(.all, Layout.spacing5x) - .background(Color(uiColor: UIColor.Background.black)) - .cornerRadius(Layout.cornerRadius6x) - .frame(width: proxy.size.width / 3) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } -} - -struct StreamDetailInputView_Previews: PreviewProvider { - static var previews: some View { - StreamDetailInputView() - } -} diff --git a/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputViewModel.swift b/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputViewModel.swift index d81e4b0c..339230ad 100644 --- a/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputViewModel.swift +++ b/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputViewModel.swift @@ -5,21 +5,36 @@ import Combine import Foundation import RTSCore +import SwiftUI @MainActor final class StreamDetailInputViewModel: ObservableObject { - private let streamDataManager: StreamDataManagerProtocol + @Binding var streamName: String + @Binding var accountID: String + @Binding var isShowingStreamingView: Bool + @Published var isShowingRecentStreams: Bool = false + @Published var isShowingErrorAlert = false + @Published var isShowingClearStreamsAlert = false + @Published private(set) var hasSavedStreams: Bool = false + private let streamDataManager: StreamDataManagerProtocol private var subscriptions: [AnyCancellable] = [] - - @Published var streamDetails: [StreamDetail] = [] { + private var streamDetails: [StreamDetail] = [] { didSet { hasSavedStreams = !streamDetails.isEmpty } } - @Published private(set) var hasSavedStreams: Bool = false - init(streamDataManager: StreamDataManagerProtocol = StreamDataManager.shared) { + init( + streamName: Binding, + accountID: Binding, + isShowingStreamingView: Binding, + isShowingRecentStreams: Binding, + streamDataManager: StreamDataManagerProtocol = StreamDataManager.shared + ) { + self._streamName = streamName + self._accountID = accountID + self._isShowingStreamingView = isShowingStreamingView self.streamDataManager = streamDataManager streamDataManager.streamDetailsSubject @@ -27,9 +42,23 @@ final class StreamDetailInputViewModel: ObservableObject { .sink { [weak self] streamDetails in self?.streamDetails = streamDetails } - .store(in: &subscriptions) + .store(in: &subscriptions) } + func playStream() { + Task { + guard checkIfCredentialsAreValid(streamName: streamName, accountID: accountID) else { + isShowingErrorAlert = true + return + } + + saveStream(streamName: streamName, accountID: accountID) + isShowingStreamingView = true + } + } +} + +private extension StreamDetailInputViewModel { func checkIfCredentialsAreValid(streamName: String, accountID: String) -> Bool { return streamName.count > 0 && accountID.count > 0 } @@ -37,8 +66,4 @@ final class StreamDetailInputViewModel: ObservableObject { func saveStream(streamName: String, accountID: String) { streamDataManager.saveStream(streamName, accountID: accountID) } - - func clearAllStreams() { - streamDataManager.clearAllStreams() - } } diff --git a/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift b/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift index 07572a18..0882e5a2 100644 --- a/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift +++ b/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift @@ -3,13 +3,12 @@ // import DolbyIOUIKit -import SwiftUI -import RTSCore -import Network import MillicastSDK +import Network +import RTSCore +import SwiftUI struct StreamingView: View { - @StateObject private var viewModel: StreamingViewModel @State private var showSettingsView = false @@ -87,7 +86,7 @@ struct StreamingView: View { ErrorView(title: title, subtitle: nil) case let .streamNotPublished(title: title, subtitle: subtitle, source: _): ErrorView(title: title, subtitle: subtitle) - case .otherError(message: let message): + case let .otherError(message: message): ErrorView(title: message, subtitle: nil) } } diff --git a/rts-viewer-tvos/RTSViewer/Utils/SourceId+Display.swift b/rts-viewer-tvos/RTSViewer/Utils/SourceId+Display.swift new file mode 100644 index 00000000..0caaf605 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Utils/SourceId+Display.swift @@ -0,0 +1,18 @@ +// +// SourceId+Display.swift +// + +import RTSCore +import DolbyIOUIKit +import SwiftUI + +extension SourceID { + var displayLabel: String { + return switch self { + case .main: + LocalizedStringKey("video-view.main.label").toString(with: .main) + case let .other(sourceId: sourceId): + sourceId + } + } +} diff --git a/rts-viewer-tvos/RTSViewer/Utils/String+Error.swift b/rts-viewer-tvos/RTSViewer/Utils/String+Error.swift new file mode 100644 index 00000000..7192e405 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Utils/String+Error.swift @@ -0,0 +1,12 @@ +// +// String+Error.swift +// + +import Foundation + +extension String { + static let offlineErrorTitle = String(localized: "stream-offline.title.label") + static let offlineErrorSubtitle = String(localized: "stream-offline.subtitle.label") + static let noInternetErrorTitle = String(localized: "network.disconnected.title.label") + static let genericErrorTitle = String(localized: "technical-error.title.label") +} diff --git a/rts-viewer-tvos/RTSViewer/VideoRenderView/VideoRendererView.swift b/rts-viewer-tvos/RTSViewer/VideoRenderView/VideoRendererView.swift new file mode 100644 index 00000000..447cff9e --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/VideoRenderView/VideoRendererView.swift @@ -0,0 +1,133 @@ +// +// VideoRendererView.swift +// + +import DolbyIOUIKit +import MillicastSDK +import RTSCore +import SwiftUI + +struct VideoRendererView: View { + @ObservedObject private var viewModel: VideoRendererViewModel + @State private var videoSize: CGSize + private let accessibilityIdentifier: String + private let action: ((StreamSource) -> Void)? + private var theme = ThemeManager.shared.theme + + init( + source: StreamSource, + isSelectedVideoSource: Bool, + isSelectedAudioSource: Bool, + showSourceLabel: Bool, + showAudioIndicator: Bool, + maxWidth: CGFloat, + maxHeight: CGFloat, + accessibilityIdentifier: String, + preferredVideoQuality: VideoQuality, + subscriptionManager: SubscriptionManager, + videoTracksManager: VideoTracksManager, + action: ((StreamSource) -> Void)? = nil + ) { + let viewModel = VideoRendererViewModel( + source: source, + isSelectedVideoSource: isSelectedVideoSource, + isSelectedAudioSource: isSelectedAudioSource, + showSourceLabel: showSourceLabel, + showAudioIndicator: showAudioIndicator, + maxWidth: maxWidth, + maxHeight: maxHeight, + preferredVideoQuality: preferredVideoQuality, + subscriptionManager: subscriptionManager, + videoTracksManager: videoTracksManager + ) + self.videoSize = viewModel.videoSize + self.accessibilityIdentifier = accessibilityIdentifier + self.action = action + self.viewModel = viewModel + } + + var body: some View { + let tileSize = viewModel.tileSize(from: videoSize) + VideoRendererViewInternal(viewModel: viewModel) + .onVideoSizeChange { + videoSize = $0 + } + .frame(width: tileSize.width, height: tileSize.height) + .accessibilityIdentifier(accessibilityIdentifier) + } +} + +private struct VideoRendererViewInternal: UIViewControllerRepresentable { + class VideoViewDelegate: MCVideoViewDelegate { + var onVideoSizeChange: ((CGSize) -> Void)? + + func didChangeVideoSize(_ size: CGSize) { + onVideoSizeChange?(size) + } + } + + private let viewModel: VideoRendererViewModel + private let delegate = VideoViewDelegate() + + init(viewModel: VideoRendererViewModel) { + self.viewModel = viewModel + } + + func makeUIViewController(context: Context) -> VideoViewController { + VideoViewController( + renderer: viewModel.renderer, + delegate: delegate + ) + } + + func updateUIViewController(_ videoViewController: VideoViewController, context: Context) { + guard videoViewController.renderer != viewModel.renderer else { return } + videoViewController.update(renderer: viewModel.renderer, delegate: delegate) + } +} + +private extension VideoRendererViewInternal { + func onVideoSizeChange(_ perform: @escaping (CGSize) -> Void) -> some View { + delegate.onVideoSizeChange = perform + return self + } +} + +private class VideoViewController: UIViewController { + private(set) var renderer: MCCMSampleBufferVideoRenderer + private weak var delegate: MCVideoViewDelegate? + private let videoView: MCSampleBufferVideoUIView + + init(renderer: MCCMSampleBufferVideoRenderer, delegate: MCVideoViewDelegate) { + self.renderer = renderer + self.delegate = delegate + self.videoView = MCSampleBufferVideoUIView(frame: .zero, renderer: renderer) + + super.init(nibName: nil, bundle: nil) + setupView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + videoView.translatesAutoresizingMaskIntoConstraints = false + videoView.delegate = delegate + view.addSubview(videoView) + + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: videoView.topAnchor), + view.leadingAnchor.constraint(equalTo: videoView.leadingAnchor), + videoView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + func update(renderer: MCCMSampleBufferVideoRenderer, delegate: MCVideoViewDelegate) { + self.renderer = renderer + self.delegate = delegate + videoView.updateRenderer(renderer) + } +} diff --git a/rts-viewer-tvos/RTSViewer/VideoRenderView/VideoRendererViewModel.swift b/rts-viewer-tvos/RTSViewer/VideoRenderView/VideoRendererViewModel.swift new file mode 100644 index 00000000..0369771d --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/VideoRenderView/VideoRendererViewModel.swift @@ -0,0 +1,116 @@ +// +// VideoRendererViewModel.swift +// + +import RTSCore +import Combine +import Foundation +import MillicastSDK +import os + +@MainActor +final class VideoRendererViewModel: ObservableObject { + private enum Constants { + static let defaultVideoTileSize = CGSize(width: 533, height: 300) + } + + let isSelectedVideoSource: Bool + let isSelectedAudioSource: Bool + let source: StreamSource + let showSourceLabel: Bool + let showAudioIndicator: Bool + let preferredVideoQuality: VideoQuality + let maxWidth: CGFloat + let maxHeight: CGFloat + let videoTracksManager: VideoTracksManager + @Published private(set) var currentVideoQuality: VideoQuality = .auto + + private let subscriptionManager: SubscriptionManager + private var subscriptions: [AnyCancellable] = [] + + init( + source: StreamSource, + isSelectedVideoSource: Bool, + isSelectedAudioSource: Bool, + showSourceLabel: Bool, + showAudioIndicator: Bool, + maxWidth: CGFloat, + maxHeight: CGFloat, + preferredVideoQuality: VideoQuality, + subscriptionManager: SubscriptionManager, + videoTracksManager: VideoTracksManager + ) { + self.source = source + self.isSelectedVideoSource = isSelectedVideoSource + self.isSelectedAudioSource = isSelectedAudioSource + self.showSourceLabel = showSourceLabel + self.showAudioIndicator = showAudioIndicator + self.maxWidth = maxWidth + self.maxHeight = maxHeight + self.preferredVideoQuality = preferredVideoQuality + self.subscriptionManager = subscriptionManager + self.videoTracksManager = videoTracksManager + + observerVideoQualityUpdates() + } + + var videoSize: CGSize { + let size = videoTracksManager.rendererRegistry.sampleBufferRenderer(for: source).underlyingRenderer.videoSize + if size.width > 0, size.height > 0 { + return size + } else { + return Constants.defaultVideoTileSize + } + } + + // swiftlint:disable force_cast + var renderer: MCCMSampleBufferVideoRenderer { + videoTracksManager.rendererRegistry.sampleBufferRenderer(for: source).underlyingRenderer as! MCCMSampleBufferVideoRenderer + } + + // swiftlint:enable force_cast + + func tileSize(from videoSize: CGSize) -> CGSize { + let ratio = calculateAspectRatio( + screenWidth: maxWidth, + screenHeight: maxHeight, + videoWidth: videoSize.width, + videoHeight: videoSize.height + ) + + let scaledWidth = videoSize.width * ratio + let scaledHeight = videoSize.height * ratio + + return CGSize(width: scaledWidth, height: scaledHeight) + } + + private func observerVideoQualityUpdates() { + Task { [weak self] in + guard let self else { return } + await self.videoTracksManager.selectedVideoQualityPublisher + .map({ $0[self.source.sourceId] ?? .auto }) + .receive(on: DispatchQueue.main) + .sink { quality in + self.currentVideoQuality = quality + } + .store(in: &subscriptions) + } + } + + private func calculateAspectRatio( + screenWidth: CGFloat, + screenHeight: CGFloat, + videoWidth: CGFloat, + videoHeight: CGFloat + ) -> CGFloat { + guard videoWidth > 0, videoHeight > 0 else { + return 1.0 + } + + if (screenWidth / videoWidth) < (screenHeight / videoHeight) { + return screenWidth / videoWidth + } else { + return screenHeight / videoHeight + } + } +}