From 2ca2b5e616bf5c5c4d0965fbd12af1632eef1063 Mon Sep 17 00:00:00 2001 From: Sheila Doherty Date: Tue, 17 Sep 2024 16:40:11 -0600 Subject: [PATCH] Add multichannel viewing support --- .DS_Store | Bin 10244 -> 0 bytes interactive-player/fastlane/.env.default | 2 +- rts-viewer-tvos/.DS_Store | Bin 6148 -> 0 bytes .../RTSViewer.xcodeproj/project.pbxproj | 162 +++++++- .../xcshareddata/swiftpm/Package.resolved | 14 - rts-viewer-tvos/RTSViewer/ContentView.swift | 16 - .../RTSViewer/Models/Channel.swift | 154 +++++++ .../RTSViewer/Models/StreamConfig.swift | 11 + .../RTSViewer/Models/UnsourcedChannel.swift | 20 + .../RTSViewer/Models/VideoQuality.swift | 22 +- rts-viewer-tvos/RTSViewer/RTSViewer.swift | 8 +- .../RTSViewer/Resources/Localizable.strings | 3 + .../SimulcastView/SimulcastView.swift | 25 +- .../StatisticsView/StatisticsView.swift | 21 +- .../StatisticsView/StatisticsViewModel.swift | 384 +++++++++--------- .../StreamDetailInputViewModel.swift | 52 --- .../RTSViewer/Utils/SourceId+Display.swift | 18 + .../RTSViewer/Utils/String+Error.swift | 12 + .../ChannelGridView/ChannelGridView.swift | 81 ++++ .../ChannelGridViewModel.swift | 33 ++ .../ChannelGridView/GridButtonStyle.swift | 22 + .../ChannelVideoView/ChannelVideoView.swift | 60 +++ .../ChannelVideoViewModel.swift | 63 +++ .../Views/ChannelView/ChannelView.swift | 58 +++ .../Views/ChannelView/ChannelViewModel.swift | 162 ++++++++ .../Views/LandingView/LandingView.swift | 96 +++++ .../Views/LandingView/LandingViewModel.swift | 39 ++ .../RecentStreamsView/RecentStreamsView.swift | 0 .../RecentStreamsViewModel.swift | 0 .../SettingsMultichannelView.swift | 153 +++++++ .../SettingsMultichannelViewModel.swift | 23 ++ .../StreamDetailInputBox.swift | 110 +++++ .../StreamDetailInputView.swift | 0 .../StreamDetailInputViewModel.swift | 117 ++++++ .../StreamingView/StreamingView.swift | 9 +- .../StreamingView/StreamingViewModel.swift | 0 .../VideoRenderView/VideoRendererView.swift | 133 ++++++ .../VideoRendererViewModel.swift | 100 +++++ rts-viewer-tvos/fastlane/.env.default | 2 +- 39 files changed, 1854 insertions(+), 331 deletions(-) delete mode 100644 .DS_Store delete mode 100644 rts-viewer-tvos/.DS_Store delete mode 100644 rts-viewer-tvos/RTSViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 rts-viewer-tvos/RTSViewer/ContentView.swift create mode 100644 rts-viewer-tvos/RTSViewer/Models/Channel.swift create mode 100644 rts-viewer-tvos/RTSViewer/Models/StreamConfig.swift create mode 100644 rts-viewer-tvos/RTSViewer/Models/UnsourcedChannel.swift delete mode 100644 rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputViewModel.swift create mode 100644 rts-viewer-tvos/RTSViewer/Utils/SourceId+Display.swift create mode 100644 rts-viewer-tvos/RTSViewer/Utils/String+Error.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/ChannelGridView/ChannelGridView.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/ChannelGridView/ChannelGridViewModel.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/ChannelGridView/GridButtonStyle.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/ChannelVideoView/ChannelVideoView.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/ChannelVideoView/ChannelVideoViewModel.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/ChannelView/ChannelView.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/ChannelView/ChannelViewModel.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/LandingView/LandingView.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/LandingView/LandingViewModel.swift rename rts-viewer-tvos/RTSViewer/{ => Views}/RecentStreamsView/RecentStreamsView.swift (100%) rename rts-viewer-tvos/RTSViewer/{ => Views}/RecentStreamsView/RecentStreamsViewModel.swift (100%) create mode 100644 rts-viewer-tvos/RTSViewer/Views/SettingsMulitChannelView/SettingsMultichannelView.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/SettingsMulitChannelView/SettingsMultichannelViewModel.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputBox.swift rename rts-viewer-tvos/RTSViewer/{ => Views}/StreamDetailInputView/StreamDetailInputView.swift (100%) create mode 100644 rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputViewModel.swift rename rts-viewer-tvos/RTSViewer/{ => Views}/StreamingView/StreamingView.swift (98%) rename rts-viewer-tvos/RTSViewer/{ => Views}/StreamingView/StreamingViewModel.swift (100%) create mode 100644 rts-viewer-tvos/RTSViewer/Views/VideoRenderView/VideoRendererView.swift create mode 100644 rts-viewer-tvos/RTSViewer/Views/VideoRenderView/VideoRendererViewModel.swift diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 63cd999d2267773b5ff9195f0ec6aed3782a7946..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMTW}ml746fKrCDoijbzJSIa=@9azM7VijXZ!j%--HoXD{)MJrjdY-hcX!Jc@u zqwK6!Ho`=K;scWKOaVm#MZO>e@)0l)9+eMBFnsU<=3$EBBS4BrK~fcpDj*-+?w(!C zt4-NZAvn}b)y$oHyKhg=-Fxnt?q!T&U_SFU#v+U{jZPsAoh}s)(Jsyv2lp&>Btia+ zk0*0Y3tM1a5{15NC@+6a4KTegymo_z_qh0Z|_!bP5^s zj3E!@np=COG-*t8dI$v;JLzEVnDf5dqi|68S~_llFFSy zxiffAhPOh2d^-6>WOs&`lK$0?fFFUC5fI$pNbB`1t=i3N*6${ZvN>kcivGN4NiDc@ zDcea=|7TKjw&mtKJAVdVRlTaFR;$(Ow3G2O&O$t&%+I?6$=q?FO*oF7D&8kkM=f(9 zVXPl?viYQyHFMIz%9zyU#FUlII12;Ld^Y1s-?(bf0$LzpG%qf8b@p_&N4k4{!ke?Ne#T zo}bOS38NvMwA1r;GH>?VcHDZGNw#`7opWq^%yBI-%(3abc`h&7Ry0k|xK1uVCTY|yxHcdM&?1MRzuF9+j+qRQKa>At-^`;Z;2m}mw z;w&YPa@D(bX#pcMX(v;r?U{Od$#mCLLB-lLHto{_(`jdRmL}~gTQJtIX~v~{#Dfnd z-TdgBnJrBzHmqqg?wMrH99^K)EqaV-n*8y|E?kB)R^FjT@z&wsVNE+G2J6pcXq?6d zqkeQO7)%(3l`U8V6#D5DUW^}Wn$}wU30h-2)NO0b`&ncxTVtKd93ZL;u`V{m4zW`N z*+up+`xJYeeU&}Oo@YO0zh*D8m)YL{a8#iNby$xkY(x-Sumd~Mjt=x;2*WslaU919 zoJ0Z{%pwa1_o0CMv4jWkK70Tl!^iOmK7r5T^Y{Xuz|;5^zK!qTS^Nk;$1iXNzr~;N z7rey5SMxfq^9?-AyZCP2&G+yg9^)f?oKNvnYU;d*%o(K@uRKY2HwrYb4DqKb11)^V zog)YDdh-D7%31-fb?cipY>WuJ_3rO4C8o;bV(Ko3ghatL*pVoZnNps?!RAf3wCJ0| zlLQmz@g4Hi(!KzJx@2*kZ^xqpSdzmKpD04mr4ZH{L^8{{Y^L2k|gIgpc5(_%t5FXJqC-fiL1q z_%gnRZ{Qhx6W_&icpjJV1N;y_!LRT;{1LB|W%%J@ZYznu-e`sw;<>EtWKUCnftEFd za)&n6lSIX1a(n$OT~8AwyG51~PpGFtkY_G&c7fR3WDAnbqhu?PyZisb-q(4}eO{)dcBR8daUB@(a5*x_w$(XO6-vEh-!cT0*|r#I=#nEAD^FUy92 zf8cVxtQMh=V)d&I#WGQ1abk2$J|hy^s$Wxys4~zvRMc(_Z4E6iTAsu9&`w>}FIh80 z#}2(ym-S0l3Srr$cZXE8Bi1I4-JOOYM`hxa!LoN zZ5fYyGdf7tl{_vQDH=z%hC)|)oQ#`-&Gn&pXi_8IuE6G%nEV|t3y?CV0$rl=icJ3G zGA926dxia-s#y(cv05_uCTt@H??w-Lu@?gvrMfnbII;K{Sh$yJ**ToYB2~2a_xnUf8d{Zm9OF(d5|~rt-PH_c?Yq0FTb7l z@gsbcPxB%?{I#?wDUWdtB1e5EB-ZTJw5}kP4;fefz}6 zwo&b=n`5LhskF2V(dV&oiP*ftSL}b1`<0ozm-78iVser5amnNf;_}q8Z&$(O7l_N3 zB$GcxT>fFn{-y<&n{xwYgr?T9x#N^k>?J^E8!r~^ARcbyF zu?H~x+W-IGWV80C<43@c!0Qb!h#vDY4<^Aw%p5dD^vlrD6U+wmmj zc04I!JHCxh(bwCzkoPgqwxpzVr#{l1q}t8@um2fvGi|iJabFGo`rlvwmv-;}1+#Fs A!2kdN diff --git a/interactive-player/fastlane/.env.default b/interactive-player/fastlane/.env.default index 72e6113f..9be7dae5 100644 --- a/interactive-player/fastlane/.env.default +++ b/interactive-player/fastlane/.env.default @@ -1,5 +1,5 @@ // .env.default -XCODE_SELECT_PATH = /Applications/Xcode_15.2.app +XCODE_SELECT_PATH = /Applications/Xcode_15.4.app // // Temp paths - Build artifacts diff --git a/rts-viewer-tvos/.DS_Store b/rts-viewer-tvos/.DS_Store deleted file mode 100644 index a894383dd3c39265480bd09fc90b233ce4049f60..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHLOG*Pl5Ph9w5pfex!DV{^AxqsjhVcM8;#%{;s0kAy5|?ubH|{)(3s2zzJch5j znwTL590d`ng6h}P-PN7<8m6WKz;*8WZJ-69Nf)fGvH8SgUVO=FaYQCv*y9QV%;;0> zF0HnKzo>xR-3Cr@iVSC1yuW+&kfYDrobWz#T#qi>SuYyhI;O}nGK%ZTRl z@!RIk!OLNl=>89)C&Ptb?L3dg)=IuJ@|@xZx8!_5{(}SBrEG@Ro;u1g{oiW&|Viw_r1cCkWn;dJir!*aMp(OOf$ z6eufD*TupWOaW8iUn$@kX_j_KDdyHCa*}I3{hTf)>k7qr2s3jC@9?}C_gOaK4? diff --git a/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj b/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj index b4adf7f5..fd579c2a 100644 --- a/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj +++ b/rts-viewer-tvos/RTSViewer.xcodeproj/project.pbxproj @@ -8,19 +8,36 @@ /* Begin PBXBuildFile section */ 3FA8DBAA2704AECD009713E8 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 3FA8DBA82704AECD009713E8 /* README.md */; }; - 631DD25826F1E18E0023D24A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE90C1D26B2AF4200B206A3 /* ContentView.swift */; }; 631DD25926F1E18E0023D24A /* RTSViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE90C1B26B2AF4200B206A3 /* RTSViewer.swift */; }; 631DD26326F1E18E0023D24A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3FE90C2226B2AF4300B206A3 /* Preview Assets.xcassets */; }; 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 /* UnsourcedChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391C32C98C9D700861FD5 /* UnsourcedChannel.swift */; }; + 89C391C72C98CA2E00861FD5 /* ChannelGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391C62C98CA2E00861FD5 /* ChannelGridView.swift */; }; + 89C391C92C98CA3D00861FD5 /* ChannelGridViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391C82C98CA3D00861FD5 /* ChannelGridViewModel.swift */; }; + 89C391D02C9A0D1700861FD5 /* StreamDetailInputBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391CF2C9A0D1700861FD5 /* StreamDetailInputBox.swift */; }; + 89C391D32C9B78A800861FD5 /* StreamDetailInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391D22C9B78A800861FD5 /* StreamDetailInputViewModel.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 */; }; 89C391E42CA21D9500861FD5 /* Bundle+Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391E32CA21D9500861FD5 /* Bundle+Version.swift */; }; + 89C391F42CA752AE00861FD5 /* SettingsMultichannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391F32CA752AE00861FD5 /* SettingsMultichannelView.swift */; }; + 89C391F82CAB552A00861FD5 /* GridButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C391F72CAB552A00861FD5 /* GridButtonStyle.swift */; }; + 89FDEBB92CAC6163003A1856 /* StreamConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FDEBB82CAC6163003A1856 /* StreamConfig.swift */; }; + 89FDEBBB2CAC9E99003A1856 /* SettingsMultichannelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FDEBBA2CAC9E99003A1856 /* SettingsMultichannelViewModel.swift */; }; + 89FDEBC42CAF0FE4003A1856 /* ChannelVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FDEBC32CAF0FE4003A1856 /* ChannelVideoView.swift */; }; + 89FDEBC62CAF0FF6003A1856 /* ChannelVideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FDEBC52CAF0FF6003A1856 /* ChannelVideoViewModel.swift */; }; + 89FDEBCB2CB443FE003A1856 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FDEBCA2CB443FE003A1856 /* Channel.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 */; }; @@ -50,7 +67,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 */ @@ -90,19 +107,35 @@ /* Begin PBXFileReference section */ 3FA8DBA82704AECD009713E8 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = SOURCE_ROOT; }; 3FE90C1B26B2AF4200B206A3 /* RTSViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTSViewer.swift; sourceTree = ""; }; - 3FE90C1D26B2AF4200B206A3 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 3FE90C2226B2AF4300B206A3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 631DD26926F1E18E0023D24A /* RTSViewer TVOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RTSViewer TVOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 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 /* UnsourcedChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsourcedChannel.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 = ""; }; + 89C391CF2C9A0D1700861FD5 /* StreamDetailInputBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDetailInputBox.swift; sourceTree = ""; }; + 89C391D22C9B78A800861FD5 /* StreamDetailInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDetailInputViewModel.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 = ""; }; 89C391E32CA21D9500861FD5 /* Bundle+Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Version.swift"; sourceTree = ""; }; + 89C391F32CA752AE00861FD5 /* SettingsMultichannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMultichannelView.swift; sourceTree = ""; }; + 89C391F72CAB552A00861FD5 /* GridButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridButtonStyle.swift; sourceTree = ""; }; + 89FDEBB82CAC6163003A1856 /* StreamConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamConfig.swift; sourceTree = ""; }; + 89FDEBBA2CAC9E99003A1856 /* SettingsMultichannelViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMultichannelViewModel.swift; sourceTree = ""; }; + 89FDEBC32CAF0FE4003A1856 /* ChannelVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelVideoView.swift; sourceTree = ""; }; + 89FDEBC52CAF0FF6003A1856 /* ChannelVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelVideoViewModel.swift; sourceTree = ""; }; + 89FDEBCA2CB443FE003A1856 /* Channel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Channel.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 = ""; }; @@ -134,7 +167,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 */ @@ -168,6 +201,7 @@ E8A0C5CA2A10B88000C1D99B /* Frameworks */, 3FE90C1926B2AF4200B206A3 /* Products */, 3FE90C1A26B2AF4200B206A3 /* RTSViewer */, + 3FA8DBA82704AECD009713E8 /* README.md */, E8752B60298CA4C4002D5C2B /* RTSViewer TVOSTests */, ); sourceTree = ""; @@ -184,19 +218,14 @@ 3FE90C1A26B2AF4200B206A3 /* RTSViewer */ = { isa = PBXGroup; children = ( - 3FE90C1D26B2AF4200B206A3 /* ContentView.swift */, - E81D7C74296FAEE400856B89 /* IDETemplateMacros.plist */, + 3FE90C1B26B2AF4200B206A3 /* RTSViewer.swift */, + 89FDEBC72CAF5AC3003A1856 /* Views */, E8752B4C298C75A4002D5C2B /* Models */, E8C47C6B29778BAA0026E877 /* Persistence */, - 3FE90C2126B2AF4300B206A3 /* Preview Content */, - 3FA8DBA82704AECD009713E8 /* README.md */, - E8D1947929717D050080C4E0 /* RecentStreamsView */, E80E2BC72C1010BE001733EA /* Resources */, E83CDA2D2A10917A008690FD /* ReusableViews */, - 3FE90C1B26B2AF4200B206A3 /* RTSViewer.swift */, - E82A0C19296D0F04007214B8 /* StreamDetailInputView */, - E844ABEA296DA4C60067B78C /* StreamingView */, E82FC5042977CF2A0050777F /* Utils */, + 3FE90C2126B2AF4300B206A3 /* Preview Content */, ); path = RTSViewer; sourceTree = ""; @@ -209,6 +238,77 @@ 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 */, + 89C391F72CAB552A00861FD5 /* GridButtonStyle.swift */, + ); + path = ChannelGridView; + sourceTree = ""; + }; + 89C391D12C9B733600861FD5 /* LandingView */ = { + isa = PBXGroup; + children = ( + E82A0C1A296D0F04007214B8 /* LandingView.swift */, + E8D5AE57299068E20019C132 /* LandingViewModel.swift */, + ); + path = LandingView; + sourceTree = ""; + }; + 89C391DA2C9CA93400861FD5 /* VideoRenderView */ = { + isa = PBXGroup; + children = ( + 89C391DB2C9CA94800861FD5 /* VideoRendererView.swift */, + 89C391DD2C9CA96600861FD5 /* VideoRendererViewModel.swift */, + ); + path = VideoRenderView; + sourceTree = ""; + }; + 89C391F22CA7528E00861FD5 /* SettingsMulitChannelView */ = { + isa = PBXGroup; + children = ( + 89C391F32CA752AE00861FD5 /* SettingsMultichannelView.swift */, + 89FDEBBA2CAC9E99003A1856 /* SettingsMultichannelViewModel.swift */, + ); + path = SettingsMulitChannelView; + sourceTree = ""; + }; + 89FDEBC22CAF0FAF003A1856 /* ChannelVideoView */ = { + isa = PBXGroup; + children = ( + 89FDEBC32CAF0FE4003A1856 /* ChannelVideoView.swift */, + 89FDEBC52CAF0FF6003A1856 /* ChannelVideoViewModel.swift */, + ); + path = ChannelVideoView; + sourceTree = ""; + }; + 89FDEBC72CAF5AC3003A1856 /* Views */ = { + isa = PBXGroup; + children = ( + 89C391D12C9B733600861FD5 /* LandingView */, + E8D1947929717D050080C4E0 /* RecentStreamsView */, + E82A0C19296D0F04007214B8 /* StreamDetailInputView */, + E844ABEA296DA4C60067B78C /* StreamingView */, + 89C391BE2C98C89400861FD5 /* ChannelView */, + 89C391C52C98CA1E00861FD5 /* ChannelGridView */, + 89FDEBC22CAF0FAF003A1856 /* ChannelVideoView */, + 89C391DA2C9CA93400861FD5 /* VideoRenderView */, + 89C391F22CA7528E00861FD5 /* SettingsMulitChannelView */, + ); + path = Views; + sourceTree = ""; + }; E80E2BC72C1010BE001733EA /* Resources */ = { isa = PBXGroup; children = ( @@ -222,8 +322,8 @@ E82A0C19296D0F04007214B8 /* StreamDetailInputView */ = { isa = PBXGroup; children = ( - E8D5AE57299068E20019C132 /* StreamDetailInputViewModel.swift */, - E82A0C1A296D0F04007214B8 /* StreamDetailInputView.swift */, + 89C391CF2C9A0D1700861FD5 /* StreamDetailInputBox.swift */, + 89C391D22C9B78A800861FD5 /* StreamDetailInputViewModel.swift */, ); path = StreamDetailInputView; sourceTree = ""; @@ -235,6 +335,8 @@ E83CDA4F2A1092A3008690FD /* ImageAsset.swift */, E8752B50298C7D02002D5C2B /* DateProvider.swift */, 89C391E32CA21D9500861FD5 /* Bundle+Version.swift */, + 89C391D82C9C9EF500861FD5 /* String+Error.swift */, + 89C391DF2C9CB5A600861FD5 /* SourceId+Display.swift */, ); path = Utils; sourceTree = ""; @@ -343,8 +445,11 @@ E8752B4C298C75A4002D5C2B /* Models */ = { isa = PBXGroup; children = ( + 89FDEBB82CAC6163003A1856 /* StreamConfig.swift */, E8752B4D298C75BD002D5C2B /* StreamDetail.swift */, E8C776D82C2BEEA5002DE392 /* VideoQuality.swift */, + 89C391C32C98C9D700861FD5 /* UnsourcedChannel.swift */, + 89FDEBCA2CB443FE003A1856 /* Channel.swift */, ); path = Models; sourceTree = ""; @@ -567,11 +672,16 @@ 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 */, + 89FDEBC42CAF0FE4003A1856 /* ChannelVideoView.swift in Sources */, 6D61A9FC299DBC30004CAF9E /* ErrorView.swift in Sources */, + 89C391C72C98CA2E00861FD5 /* ChannelGridView.swift in Sources */, E8B48E86297A2957000DC59A /* RecentStreamButton.swift in Sources */, + 89FDEBC62CAF0FF6003A1856 /* ChannelVideoViewModel.swift in Sources */, E8752B52298C7D02002D5C2B /* DateProvider.swift in Sources */, E8D1947C29717D410080C4E0 /* RecentStreamsView.swift in Sources */, E8BA8E1D2991EED30043DEE1 /* SimulcastView.swift in Sources */, @@ -582,23 +692,35 @@ 89C391E42CA21D9500861FD5 /* Bundle+Version.swift in Sources */, E8BA8E102991CB0E0043DEE1 /* StreamingViewModel.swift in Sources */, E8C776D92C2BEEA5002DE392 /* VideoQuality.swift in Sources */, - 631DD25826F1E18E0023D24A /* ContentView.swift in Sources */, + 89C391F42CA752AE00861FD5 /* SettingsMultichannelView.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 */, + 89C391F82CAB552A00861FD5 /* GridButtonStyle.swift in Sources */, + 89C391C22C98C9A900861FD5 /* ChannelViewModel.swift in Sources */, + 89C391DE2C9CA96600861FD5 /* VideoRendererViewModel.swift in Sources */, + 89C391C42C98C9D700861FD5 /* UnsourcedChannel.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 */, 631DD25926F1E18E0023D24A /* RTSViewer.swift in Sources */, + 89FDEBBB2CAC9E99003A1856 /* SettingsMultichannelViewModel.swift in Sources */, + 89FDEBB92CAC6163003A1856 /* StreamConfig.swift in Sources */, E8BA8E192991E8B30043DEE1 /* SimulcastViewModel.swift in Sources */, + 89C391D92C9C9EF500861FD5 /* String+Error.swift in Sources */, E8C47C7229778BDB0026E877 /* RTSViewer.xcdatamodeld in Sources */, E8752B5A298C9FB6002D5C2B /* RecentStreamButtonViewModel.swift in Sources */, + 89FDEBCB2CB443FE003A1856 /* Channel.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.xcworkspace/xcshareddata/swiftpm/Package.resolved b/rts-viewer-tvos/RTSViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 3cbe83d7..00000000 --- a/rts-viewer-tvos/RTSViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "millicast-sdk-swift-package", - "kind" : "remoteSourceControl", - "location" : "https://github.com/millicast/millicast-sdk-swift-package", - "state" : { - "revision" : "d20cb45ff24acbc16191d4df6a0e4be9daa26e24", - "version" : "2.0.0-beta.7" - } - } - ], - "version" : 2 -} diff --git a/rts-viewer-tvos/RTSViewer/ContentView.swift b/rts-viewer-tvos/RTSViewer/ContentView.swift deleted file mode 100644 index f6cafada..00000000 --- a/rts-viewer-tvos/RTSViewer/ContentView.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ContentView.swift -// Millicast SDK Sample App in Swift -// - -import SwiftUI - -struct ContentView: View { - - var body: some View { - NavigationView { - StreamDetailInputView() - } - .navigationViewStyle(StackNavigationViewStyle()) - } -} diff --git a/rts-viewer-tvos/RTSViewer/Models/Channel.swift b/rts-viewer-tvos/RTSViewer/Models/Channel.swift new file mode 100644 index 00000000..c2673780 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Models/Channel.swift @@ -0,0 +1,154 @@ +// +// SourcedChannel.swift +// + +import Combine +import Foundation +import os +import MillicastSDK +import RTSCore + +class Channel: ObservableObject, Identifiable, Hashable, Equatable { + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: Channel.self) + ) + + @Published var currentlyFocusedChannel: Channel? { + didSet { + guard let currentlyFocusedChannel else { return } + isFocusedChannel = currentlyFocusedChannel.id == id + } + } + + @Published var isFocusedChannel: Bool = false { + didSet { + if isFocusedChannel { + enableSound() + } else { + disableSound() + } + } + } + + @Published private(set) var streamStatistics: StreamStatistics? + @Published var showStatsView: Bool = false + @Published var videoQualityList = [VideoQuality]() + @Published var selectedVideoQuality: VideoQuality = .auto + + let id: UUID + let streamConfig: StreamConfig + let subscriptionManager: SubscriptionManager + let source: StreamSource + let rendererRegistry: RendererRegistry + private var cancellables = [AnyCancellable]() + private var layersEventsObserver: Task? + + init(unsourcedChannel: UnsourcedChannel, + source: StreamSource, + rendererRegistry: RendererRegistry = RendererRegistry()) { + self.id = unsourcedChannel.id + self.streamConfig = unsourcedChannel.streamConfig + self.subscriptionManager = unsourcedChannel.subscriptionManager + self.source = source + self.rendererRegistry = rendererRegistry + + observeStreamStatistics() + observeLayerEvents() + } + + func shouldShowStatsView(showStats: Bool) { + showStatsView = showStats + } + + func enableVideo(with quality: VideoQuality) { + let displayLabel = source.sourceId.displayLabel + let viewId = "\(ChannelGridView.self).\(displayLabel)" + Task { + Self.logger.debug("♼ Channel Grid view: Video view appear for \(self.source.sourceId)") + self.selectedVideoQuality = quality + if let layer = quality.layer { + try await self.source.videoTrack.enable( + renderer: rendererRegistry.sampleBufferRenderer(for: source).underlyingRenderer, + layer: MCRTSRemoteVideoTrackLayer(layer: layer) + ) + } else { + try await self.source.videoTrack.enable(renderer: rendererRegistry.sampleBufferRenderer(for: source).underlyingRenderer) + } + } + } + + func disableVideo() { + let displayLabel = source.sourceId.displayLabel + Task { + Self.logger.debug("♼ Channel Grid view: Video view disappear for \(self.source.sourceId)") + try await self.source.videoTrack.disable() + } + } + + func enableSound() { + Task { + try? await self.source.audioTrack?.enable() + Self.logger.debug("♼ Channel \(self.source.sourceId) audio enabled") + } + } + + func disableSound() { + Task { + try? await self.source.audioTrack?.disable() + Self.logger.debug("♼ Channel \(self.source.sourceId) audio disabled") + } + } + + func updateFocusedChannel(with channel: Channel) { + currentlyFocusedChannel = channel + } + + static func == (lhs: Channel, rhs: Channel) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + return hasher.combine(id) + } +} + +private extension Channel { + func observeStreamStatistics() { + Task { [weak self] in + guard let self else { return } + await subscriptionManager.$streamStatistics + .sink { statistics in + guard let statistics else { return } + Task { + self.streamStatistics = statistics + } + } + .store(in: &cancellables) + } + } + + func observeLayerEvents() { + Task { [weak self] in + guard let self, + layersEventsObserver == nil else { return } + + Self.logger.debug("♼ Registering layer events for \(source.sourceId)") + let layerEventsObservationTask = Task { + for await layerEvent in self.source.videoTrack.layers() { + guard !Task.isCancelled else { return } + + let videoQualities = layerEvent.layers() + .map(VideoQuality.init) + .reduce([.auto]) { $0 + [$1] } + Self.logger.debug("♼ Received layers \(videoQualities.count)") + self.videoQualityList = videoQualities + } + } + + layersEventsObserver = layerEventsObservationTask + + _ = await layerEventsObservationTask.value + } + } +} diff --git a/rts-viewer-tvos/RTSViewer/Models/StreamConfig.swift b/rts-viewer-tvos/RTSViewer/Models/StreamConfig.swift new file mode 100644 index 00000000..1ee9020d --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Models/StreamConfig.swift @@ -0,0 +1,11 @@ +// +// PlayFromConfigs.swift +// + +import Foundation + +struct StreamConfig { + let apiUrl: String + let streamName: String + let accountId: String +} diff --git a/rts-viewer-tvos/RTSViewer/Models/UnsourcedChannel.swift b/rts-viewer-tvos/RTSViewer/Models/UnsourcedChannel.swift new file mode 100644 index 00000000..cb1e7b1c --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Models/UnsourcedChannel.swift @@ -0,0 +1,20 @@ +// +// UnsourcedChannel.swift +// + +import Foundation +import RTSCore + +struct UnsourcedChannel: Identifiable, Hashable, Equatable { + let id = UUID() + let streamConfig: StreamConfig + let subscriptionManager: SubscriptionManager + + static func == (lhs: UnsourcedChannel, rhs: UnsourcedChannel) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + return hasher.combine(id) + } +} diff --git a/rts-viewer-tvos/RTSViewer/Models/VideoQuality.swift b/rts-viewer-tvos/RTSViewer/Models/VideoQuality.swift index 7c0ac3c2..91da4fa4 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 { case auto case quality(MCRTSRemoteTrackLayer) @@ -24,6 +15,15 @@ enum VideoQuality: Identifiable { } extension VideoQuality { + var id: String { + switch self { + case .auto: + "Auto" + case let .quality(videoTrackLayer): + videoTrackLayer.encodingId + } + } + var displayText: String { switch self { case .auto: @@ -57,7 +57,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/RTSViewer.swift b/rts-viewer-tvos/RTSViewer/RTSViewer.swift index 75e15249..979ad0f5 100644 --- a/rts-viewer-tvos/RTSViewer/RTSViewer.swift +++ b/rts-viewer-tvos/RTSViewer/RTSViewer.swift @@ -10,11 +10,13 @@ import SwiftUI */ @main struct RTSViewer: App { - var body: some Scene { WindowGroup { - ContentView() - .preferredColorScheme(.dark) + NavigationView { + LandingView() + } + .navigationViewStyle(StackNavigationViewStyle()) + .preferredColorScheme(.dark) } } } diff --git a/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings b/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings index 338ab379..13d8ef57 100644 --- a/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings +++ b/rts-viewer-tvos/RTSViewer/Resources/Localizable.strings @@ -11,6 +11,7 @@ "stream-detail-input.streamName.placeholder.label" = "Enter your stream name"; "stream-detail-input.accountId.placeholder.label" = "Enter your account ID"; "stream-detail-input.play.button" = "Play"; +"stream-detail-input.play-from-config.button" = "Play From Config"; "stream-detail-input.recent-streams.button" = "Saved Streams"; "stream-detail-input.clear-stream-history.button" = "Clear stream history"; "stream-detail-input.rememberStream.toggle" = "Remember stream"; @@ -88,3 +89,5 @@ "stream.stats.total-stream-time.label" = "Total Stream Time"; "stream.stats.target-bitrate.label" = "Target Bitrate"; "stream.stats.outgoing-bitrate.label" = "Outgoing Bitrate"; + +"video-view.main.label" = "Main"; diff --git a/rts-viewer-tvos/RTSViewer/ReusableViews/SimulcastView/SimulcastView.swift b/rts-viewer-tvos/RTSViewer/ReusableViews/SimulcastView/SimulcastView.swift index 4d2c30b6..6f54c869 100644 --- a/rts-viewer-tvos/RTSViewer/ReusableViews/SimulcastView/SimulcastView.swift +++ b/rts-viewer-tvos/RTSViewer/ReusableViews/SimulcastView/SimulcastView.swift @@ -16,7 +16,12 @@ struct SimulcastView: View { private let onSelectVideoQuality: (VideoQuality) -> Void @FocusState private var focusedVideoQuality: FocusableField? - init(source: StreamSource, videoQualityList: [VideoQuality], selectedVideoQuality: VideoQuality, onSelectVideoQuality: @escaping (VideoQuality) -> Void) { + init( + source: StreamSource, + videoQualityList: [VideoQuality], + selectedVideoQuality: VideoQuality, + onSelectVideoQuality: @escaping (VideoQuality) -> Void + ) { viewModel = SimulcastViewModel(source: source, videoQualityList: videoQualityList, selectedVideoQuality: selectedVideoQuality) self.onSelectVideoQuality = onSelectVideoQuality } @@ -62,16 +67,16 @@ struct SimulcastView: View { onSelectVideoQuality(videoQuality) }, label: { HStack { - VStack(alignment: .leading) { - Text(videoQuality.displayText) - .font(theme[.avenirNextDemiBold(size: FontSize.body, style: .body)]) - if let targetInformation = videoQuality.targetInformation { - Text(targetInformation) - .font(theme[.avenirNextRegular(size: FontSize.caption2, style: .caption2)]) - .fixedSize(horizontal: false, vertical: true) - .multilineTextAlignment(.leading) + VStack(alignment: .leading) { + Text(videoQuality.displayText) + .font(theme[.avenirNextDemiBold(size: FontSize.body, style: .body)]) + if let targetInformation = videoQuality.targetInformation { + Text(targetInformation) + .font(theme[.avenirNextRegular(size: FontSize.caption2, style: .caption2)]) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } } - } Spacer() if videoQuality.encodingId == viewModel.selectedVideoQuality.encodingId { diff --git a/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsView.swift b/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsView.swift index ddaa565b..8fda4450 100644 --- a/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsView.swift +++ b/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsView.swift @@ -10,28 +10,31 @@ import SwiftUI struct StatisticsView: View { private let viewModel: StatisticsViewModel + private let fontAssetTable = FontAsset.avenirNextRegular(size: FontSize.caption2, style: .caption2) + private let fontTable = Font.avenirNextRegular(withStyle: .caption2, size: FontSize.caption2) + private let fontAssetCaption = FontAsset.avenirNextDemiBold(size: FontSize.caption1, style: .caption) + private let fontAssetTitle: FontAsset + private let theme = ThemeManager.shared.theme + private let isMultiChannel: Bool init( source: StreamSource, streamStatistics: StreamStatistics, layers: [MCRTSRemoteTrackLayer], - projectedTimeStamp: Double? + projectedTimeStamp: Double?, + isMultiChannel: Bool = false ) { viewModel = StatisticsViewModel( source: source, streamStatistics: streamStatistics, layers: layers, - projectedTimeStamp: projectedTimeStamp + projectedTimeStamp: projectedTimeStamp, + isMultiChannel: isMultiChannel ) + fontAssetTitle = isMultiChannel ? FontAsset.avenirNextBold(size: FontSize.caption1, style: .caption) : FontAsset.avenirNextBold(size: FontSize.title3, style: .title3) + self.isMultiChannel = isMultiChannel } - private let fontAssetTable = FontAsset.avenirNextRegular(size: FontSize.caption2, style: .caption2) - private let fontTable = Font.avenirNextRegular(withStyle: .caption2, size: FontSize.caption2) - - private let fontAssetCaption = FontAsset.avenirNextDemiBold(size: FontSize.caption1, style: .caption) - private let fontAssetTitle = FontAsset.avenirNextBold(size: FontSize.title3, style: .title3) - private let theme = ThemeManager.shared.theme - var body: some View { VStack { Text(text: "stream.media-stats.label", fontAsset: fontAssetTitle) diff --git a/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsViewModel.swift b/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsViewModel.swift index acb09479..b00ad312 100644 --- a/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsViewModel.swift +++ b/rts-viewer-tvos/RTSViewer/ReusableViews/StatisticsView/StatisticsViewModel.swift @@ -23,14 +23,16 @@ final class StatisticsViewModel: ObservableObject { source: StreamSource, streamStatistics: StreamStatistics, layers: [MCRTSRemoteTrackLayer], - projectedTimeStamp: Double? + projectedTimeStamp: Double?, + isMultiChannel: Bool = false ) { self.source = source statsItems = Self.makeStatsList( from: source, streamStatistics: streamStatistics, layers: layers, - projectedTimeStamp: projectedTimeStamp + projectedTimeStamp: projectedTimeStamp, + isMultiChannel: isMultiChannel ) } } @@ -43,7 +45,8 @@ private extension StatisticsViewModel { from source: StreamSource, streamStatistics: StreamStatistics, layers: [MCRTSRemoteTrackLayer], - projectedTimeStamp: Double? + projectedTimeStamp: Double?, + isMultiChannel: Bool = false ) -> [StatsItem] { var result = [StatsItem]() guard let videoStatsInboundRtp = streamStatistics.videoStatsInboundRtpList.first(where: { $0.mid == source.videoTrack.currentMID }) else { @@ -145,268 +148,271 @@ private extension StatisticsViewModel { ) } - let packetsReceived = videoStatsInboundRtp.packetsReceived - result.append( - StatsItem( - key: String(localized: "stream.stats.packets-received.label"), - value: String(packetsReceived) - ) - ) - - let framesDecoded = videoStatsInboundRtp.framesDecoded - result.append( - StatsItem( - key: String(localized: "stream.stats.frames-decoded.label"), - value: String(framesDecoded) - ) - ) - - let framesDropped = videoStatsInboundRtp.framesDropped - if framesDropped > 0 { + if !isMultiChannel { + let packetsReceived = videoStatsInboundRtp.packetsReceived result.append( StatsItem( - key: String(localized: "stream.stats.frames-dropped.label"), - value: String(framesDropped) + key: String(localized: "stream.stats.packets-received.label"), + value: String(packetsReceived) ) ) - } - - let jitterBufferEmittedCount = videoStatsInboundRtp.jitterBufferEmittedCount - result.append( - StatsItem( - key: String(localized: "stream.stats.jitter-buffer-est-count.label"), - value: String(jitterBufferEmittedCount) - ) - ) - - let videoJitter = videoStatsInboundRtp.jitter - result.append( - StatsItem( - key: String(localized: "stream.stats.video-jitter.label"), - value: "\(videoJitter) ms" - ) - ) - if let audioJitter = audioStatsInboundRtp?.jitter { + let framesDecoded = videoStatsInboundRtp.framesDecoded result.append( StatsItem( - key: String(localized: "stream.stats.audio-jitter.label"), - value: "\(audioJitter) ms" + key: String(localized: "stream.stats.frames-decoded.label"), + value: String(framesDecoded) ) ) - } - - let jitterBufferDelay = videoStatsInboundRtp.jitterBufferDelay - result.append( - StatsItem( - key: String(localized: "stream.stats.jitter-buffer-delay.label"), - value: String(format: "%.2f ms", jitterBufferDelay) - ) - ) - - let jitterBufferTargetDelay = videoStatsInboundRtp.jitterBufferTargetDelay - result.append( - StatsItem( - key: String(localized: "stream.stats.jitter-buffer-target-delay.label"), - value: String(format: "%.2f ms", jitterBufferTargetDelay) - ) - ) - let jitterBufferMinimumDelay = videoStatsInboundRtp.jitterBufferMinimumDelay - result.append( - StatsItem( - key: String(localized: "stream.stats.jitter-buffer-minimum-delay.label"), - value: String(format: "%.2f ms", jitterBufferMinimumDelay) - ) - ) + let framesDropped = videoStatsInboundRtp.framesDropped + if framesDropped > 0 { + result.append( + StatsItem( + key: String(localized: "stream.stats.frames-dropped.label"), + value: String(framesDropped) + ) + ) + } - let videoPacketsLost = videoStatsInboundRtp.packetsLost - if videoPacketsLost > 0 { + let jitterBufferEmittedCount = videoStatsInboundRtp.jitterBufferEmittedCount result.append( StatsItem( - key: String(localized: "stream.stats.video-packet-loss.label"), - value: String(videoPacketsLost) + key: String(localized: "stream.stats.jitter-buffer-est-count.label"), + value: String(jitterBufferEmittedCount) ) ) - } - if let audioPacketsLost = audioStatsInboundRtp?.packetsLost, - audioPacketsLost > 0 { + let videoJitter = videoStatsInboundRtp.jitter result.append( StatsItem( - key: String(localized: "stream.stats.audio-packet-loss.label"), - value: String(audioPacketsLost) + key: String(localized: "stream.stats.video-jitter.label"), + value: "\(videoJitter) ms" ) ) - } - let freezeCount = videoStatsInboundRtp.freezeCount - if freezeCount > 0 { - result.append( - StatsItem( - key: String(localized: "stream.stats.freeze-count.label"), - value: String(freezeCount) + if let audioJitter = audioStatsInboundRtp?.jitter { + result.append( + StatsItem( + key: String(localized: "stream.stats.audio-jitter.label"), + value: "\(audioJitter) ms" + ) ) - ) - } + } - let freezeDuration = videoStatsInboundRtp.freezeDuration - if freezeDuration > 0 { + let jitterBufferDelay = videoStatsInboundRtp.jitterBufferDelay result.append( StatsItem( - key: String(localized: "stream.stats.freeze-duration.label"), - value: String(format: "%.2f ms", freezeDuration) + key: String(localized: "stream.stats.jitter-buffer-delay.label"), + value: String(format: "%.2f ms", jitterBufferDelay) ) ) - } - let pauseCount = videoStatsInboundRtp.pauseCount - if pauseCount > 0 { + let jitterBufferTargetDelay = videoStatsInboundRtp.jitterBufferTargetDelay result.append( StatsItem( - key: String(localized: "stream.stats.pause-count.label"), - value: String(pauseCount) + key: String(localized: "stream.stats.jitter-buffer-target-delay.label"), + value: String(format: "%.2f ms", jitterBufferTargetDelay) ) ) - } - let pauseDuration = videoStatsInboundRtp.pauseDuration - if pauseDuration > 0 { + let jitterBufferMinimumDelay = videoStatsInboundRtp.jitterBufferMinimumDelay result.append( StatsItem( - key: String(localized: "stream.stats.pause-duration.label"), - value: String(format: "%.2f ms", pauseDuration) + key: String(localized: "stream.stats.jitter-buffer-minimum-delay.label"), + value: String(format: "%.2f ms", jitterBufferMinimumDelay) ) ) - } - if let videoStatsOutboundRtp = streamStatistics.outboundVideoStatistics() { - let retransmittedPackets = videoStatsOutboundRtp.retransmittedPackets - result.append( - StatsItem( - key: String(localized: "stream.stats.retransmitted-packets.label"), - value: String(retransmittedPackets) + let videoPacketsLost = videoStatsInboundRtp.packetsLost + if videoPacketsLost > 0 { + result.append( + StatsItem( + key: String(localized: "stream.stats.video-packet-loss.label"), + value: String(videoPacketsLost) + ) ) - ) + } - let retransmittedBytes = videoStatsOutboundRtp.retransmittedBytes - result.append( - StatsItem( - key: String(localized: "stream.stats.retransmitted-bytes.label"), - value: String(retransmittedBytes) + if let audioPacketsLost = audioStatsInboundRtp?.packetsLost, + audioPacketsLost > 0 { + result.append( + StatsItem( + key: String(localized: "stream.stats.audio-packet-loss.label"), + value: String(audioPacketsLost) + ) ) - ) - } + } - if let currentRTT = streamStatistics.currentRoundTripTime { - result.append( - StatsItem( - key: String(localized: "stream.stats.current-rtt.label"), - value: String(currentRTT) + let freezeCount = videoStatsInboundRtp.freezeCount + if freezeCount > 0 { + result.append( + StatsItem( + key: String(localized: "stream.stats.freeze-count.label"), + value: String(freezeCount) + ) ) - ) - } + } - if let totalRTT = streamStatistics.totalRoundTripTime { - result.append( - StatsItem( - key: String(localized: "stream.stats.total-rtt.label"), - value: String(totalRTT) + let freezeDuration = videoStatsInboundRtp.freezeDuration + if freezeDuration > 0 { + result.append( + StatsItem( + key: String(localized: "stream.stats.freeze-duration.label"), + value: String(format: "%.2f ms", freezeDuration) + ) ) - ) - } + } - let timestamp = videoStatsInboundRtp.timestamp - result.append( - StatsItem( - key: String(localized: "stream.stats.timestamp.label"), - value: dateString(timestamp / 1000) - ) - ) + let pauseCount = videoStatsInboundRtp.pauseCount + if pauseCount > 0 { + result.append( + StatsItem( + key: String(localized: "stream.stats.pause-count.label"), + value: String(pauseCount) + ) + ) + } - 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 pauseDuration = videoStatsInboundRtp.pauseDuration + if pauseDuration > 0 { + result.append( + StatsItem( + key: String(localized: "stream.stats.pause-duration.label"), + value: String(format: "%.2f ms", pauseDuration) + ) + ) + } + + if let videoStatsOutboundRtp = streamStatistics.outboundVideoStatistics() { + let retransmittedPackets = videoStatsOutboundRtp.retransmittedPackets + result.append( + StatsItem( + key: String(localized: "stream.stats.retransmitted-packets.label"), + value: String(retransmittedPackets) + ) ) - ) - } - let audioCodec = audioStatsInboundRtp?.codecName - let videoCodec = videoStatsInboundRtp.codecName - if audioCodec != nil || videoCodec != nil { - var delimiter = ", " - if audioCodec == nil || videoCodec == nil { - delimiter = "" + let retransmittedBytes = videoStatsOutboundRtp.retransmittedBytes + result.append( + StatsItem( + key: String(localized: "stream.stats.retransmitted-bytes.label"), + value: String(retransmittedBytes) + ) + ) } - let codecs = "\(audioCodec ?? "")\(delimiter)\(videoCodec ?? "")" - result.append( - StatsItem( - key: String(localized: "stream.stats.codecs.label"), - value: codecs + + if let currentRTT = streamStatistics.currentRoundTripTime { + result.append( + StatsItem( + key: String(localized: "stream.stats.current-rtt.label"), + value: String(currentRTT) + ) ) - ) - } + } - if let projectedTimeStamp { - let bitsReceived = videoStatsInboundRtp.bytesReceived * 8 - let totalTimeInSeconds = (videoStatsInboundRtp.timestamp - projectedTimeStamp) / 1000 - let incomingBitRate = Double(bitsReceived) / totalTimeInSeconds + if let totalRTT = streamStatistics.totalRoundTripTime { + result.append( + StatsItem( + key: String(localized: "stream.stats.total-rtt.label"), + value: String(totalRTT) + ) + ) + } + let timestamp = videoStatsInboundRtp.timestamp result.append( StatsItem( - key: String(localized: "stream.stats.incoming-bitrate.label"), - value: formatBitRate(bitRate: incomingBitRate) + key: String(localized: "stream.stats.timestamp.label"), + value: dateString(timestamp / 1000) ) ) - } - 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 { + if let projectedTimeStamp { + let totalTime = videoStatsInboundRtp.timestamp - projectedTimeStamp result.append( StatsItem( - key: String(localized: "stream.stats.target-bitrate.label"), - value: Self.formatBitRate(bitRate: targetBitrate.doubleValue) + key: String(localized: "stream.stats.total-stream-time.label"), + value: elapsedTimeString(totalTime / 1000) ) ) - } else { + } + + let audioCodec = audioStatsInboundRtp?.codecName + let videoCodec = videoStatsInboundRtp.codecName + if audioCodec != nil || videoCodec != nil { + var delimiter = ", " + if audioCodec == nil || videoCodec == nil { + delimiter = "" + } + let codecs = "\(audioCodec ?? "")\(delimiter)\(videoCodec ?? "")" result.append( StatsItem( - key: String(localized: "stream.stats.target-bitrate.label"), - value: "N/A" + key: String(localized: "stream.stats.codecs.label"), + value: codecs ) ) } - result.append( - StatsItem( - key: String(localized: "stream.stats.outgoing-bitrate.label"), - value: Self.formatBitRate(bitRate: Double(selectedLayer.bitrate)) + 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) + ) ) - ) - } else { - result.append( - StatsItem( - key: String(localized: "stream.stats.target-bitrate.label"), - value: "N/A" + } + + 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" + result.append( + StatsItem( + key: String(localized: "stream.stats.outgoing-bitrate.label"), + value: "N/A" + ) ) - ) + } } return result } + // swiftlint:enable function_body_length cyclomatic_complexity static func dateString(_ timestamp: Double) -> String { diff --git a/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputViewModel.swift b/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputViewModel.swift deleted file mode 100644 index 5a8136f6..00000000 --- a/rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputViewModel.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// StreamDetailInputViewModel.swift -// - -import Combine -import Foundation -import MillicastSDK -import RTSCore - -@MainActor -final class StreamDetailInputViewModel: ObservableObject { - @Published private(set) var hasSavedStreams: Bool = false - @Published var streamDetails: [StreamDetail] = [] { - didSet { - hasSavedStreams = !streamDetails.isEmpty - } - } - - let sdkVersion = "SDK Version \(MCLogger.getVersion())" - var appVersion: String = "" - private let streamDataManager: StreamDataManagerProtocol - private var subscriptions: [AnyCancellable] = [] - - init(streamDataManager: StreamDataManagerProtocol = StreamDataManager.shared) { - self.streamDataManager = streamDataManager - - if let version = Bundle.main.releaseVersionNumber, - let build = Bundle.main.buildVersionNumber - { - appVersion = "App Version \(version) \(build)" - } - - streamDataManager.streamDetailsSubject - .receive(on: DispatchQueue.main) - .sink { [weak self] streamDetails in - self?.streamDetails = streamDetails - } - .store(in: &subscriptions) - } - - func checkIfCredentialsAreValid(streamName: String, accountID: String) -> Bool { - return streamName.count > 0 && accountID.count > 0 - } - - func saveStream(streamName: String, accountID: String) { - streamDataManager.saveStream(streamName, accountID: accountID) - } - - func clearAllStreams() { - streamDataManager.clearAllStreams() - } -} 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..6dbeffb0 --- /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 { + switch self { + case .main: + return LocalizedStringKey("video-view.main.label").toString(with: .main) + case let .other(sourceId: sourceId): + return 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/Views/ChannelGridView/ChannelGridView.swift b/rts-viewer-tvos/RTSViewer/Views/ChannelGridView/ChannelGridView.swift new file mode 100644 index 00000000..69a88004 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/ChannelGridView/ChannelGridView.swift @@ -0,0 +1,81 @@ +// +// ChannelGridView.swift +// + +import DolbyIOUIKit +import MillicastSDK +import RTSCore +import SwiftUI + +struct ChannelGridView: View { + static let numberOfColumns = 2 + @ObservedObject var viewModel: ChannelGridViewModel + @FocusState var focusedView: FocusedView? + @State private var showSettingsView = false + @State private var showStatsView = false + + enum FocusedView: Hashable, Equatable { + case gridView(Channel) + case settings + } + + init(viewModel: ChannelGridViewModel) { + self.viewModel = viewModel + } + + var body: some View { + BackgroundContainerView { + 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(Array(viewModel.channels.enumerated()), id: \.offset) { _, channel in + Button { + withAnimation { + showSettingsView.toggle() + } + } label: { + let channelVideoViewModel = ChannelVideoViewModel(channel: channel) + ChannelVideoView(viewModel: channelVideoViewModel, width: tileWidth) + } + .focused($focusedView, equals: .gridView(channel)) + .disabled(showSettingsView) + .buttonStyle(GridButtonStyle(focusedView: focusedView, currentChannel: channel, focusedBorderColor: .purple)) + .onAppear { + viewModel.onAppear(for: channel) + } + .onDisappear { + viewModel.onDisappear(for: channel) + } + .id(channel.source.id) + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + .overlay(alignment: .trailing) { + if showSettingsView, + let currentlyFocusedChannel = viewModel.getCurrentlyFocusedChannel() { + settingsView(for: currentlyFocusedChannel) + .focused($focusedView, equals: .settings) + } + } + .onChange(of: focusedView ?? .settings) { focus in + if case let .gridView(focusedChannel) = focus { + viewModel.updateFocus(with: focusedChannel) + } + } + } + } + } + + @ViewBuilder + func settingsView(for channel: Channel) -> some View { + let settingViewModel = SettingsMultichannelViewModel(channel: channel) + SettingsMultichannelView(viewModel: settingViewModel, showSettingsView: $showSettingsView, showStatsView: channel.showStatsView) + } +} + +#Preview { + ChannelGridView(viewModel: ChannelGridViewModel(channels: [])) +} diff --git a/rts-viewer-tvos/RTSViewer/Views/ChannelGridView/ChannelGridViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/ChannelGridView/ChannelGridViewModel.swift new file mode 100644 index 00000000..96e5cec6 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/ChannelGridView/ChannelGridViewModel.swift @@ -0,0 +1,33 @@ +// +// ChannelGridViewModel.swift +// + +import Foundation + +@MainActor +final class ChannelGridViewModel: ObservableObject { + @Published var channels: [Channel] + + init(channels: [Channel]) { + self.channels = channels + } + + func onAppear(for channel: Channel) { + channel.enableVideo(with: .auto) + } + + func onDisappear(for channel: Channel) { + channel.disableVideo() + channel.disableSound() + } + + func updateFocus(with focusedChannel: Channel) { + channels.forEach { channel in + channel.updateFocusedChannel(with: focusedChannel) + } + } + + func getCurrentlyFocusedChannel() -> Channel? { + return channels[0].currentlyFocusedChannel + } +} diff --git a/rts-viewer-tvos/RTSViewer/Views/ChannelGridView/GridButtonStyle.swift b/rts-viewer-tvos/RTSViewer/Views/ChannelGridView/GridButtonStyle.swift new file mode 100644 index 00000000..9b14ac23 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/ChannelGridView/GridButtonStyle.swift @@ -0,0 +1,22 @@ +// +// GridButtonStyle.swift +// + +import SwiftUI + +struct GridButtonStyle: ButtonStyle { + var isFocused: Bool = false + let focusedBorderColor: Color + + init(focusedView: ChannelGridView.FocusedView?, currentChannel: Channel, focusedBorderColor: Color) { + if case let .gridView(channel) = focusedView { + isFocused = currentChannel == channel + } + self.focusedBorderColor = focusedBorderColor + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .border(isFocused ? focusedBorderColor : .gray, width: 2) + } +} diff --git a/rts-viewer-tvos/RTSViewer/Views/ChannelVideoView/ChannelVideoView.swift b/rts-viewer-tvos/RTSViewer/Views/ChannelVideoView/ChannelVideoView.swift new file mode 100644 index 00000000..908a05c9 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/ChannelVideoView/ChannelVideoView.swift @@ -0,0 +1,60 @@ +// +// ChannelVideoView.swift +// + +import DolbyIOUIKit +import SwiftUI + +struct ChannelVideoView: View { + @ObservedObject var viewModel: ChannelVideoViewModel + let width: CGFloat + + init(viewModel: ChannelVideoViewModel, width: CGFloat) { + self.viewModel = viewModel + self.width = width + } + + var body: some View { + VStack { + let channel = viewModel.channel + VideoRendererView(source: channel.source, + isSelectedVideoSource: true, + isSelectedAudioSource: true, + showSourceLabel: false, + showAudioIndicator: false, + maxWidth: width, + maxHeight: .infinity, + accessibilityIdentifier: "ChannelVideoView.\(channel.source.sourceId.displayLabel)", + preferredVideoQuality: .auto, + subscriptionManager: channel.subscriptionManager, + rendererRegistry: channel.rendererRegistry) + .overlay(alignment: .bottomTrailing) { + IconView(name: .settings) + .padding() + .opacity(viewModel.isFocused ? 1 : 0) + .animation(.easeInOut, value: viewModel.isFocused) + } + .overlay(alignment: .bottomLeading) { + if viewModel.showStatsView, + let streamStatistics = viewModel.statistics { + let videoQualityList = viewModel.videoQualityList + StatisticsView( + source: channel.source, + streamStatistics: streamStatistics, + layers: videoQualityList.compactMap { + switch $0 { + case .auto: + return nil + case let .quality(layer): + return layer + } + }, + projectedTimeStamp: nil, + isMultiChannel: true + ) + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + } +} diff --git a/rts-viewer-tvos/RTSViewer/Views/ChannelVideoView/ChannelVideoViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/ChannelVideoView/ChannelVideoViewModel.swift new file mode 100644 index 00000000..18ddb160 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/ChannelVideoView/ChannelVideoViewModel.swift @@ -0,0 +1,63 @@ +// +// ChannelVideoViewModel.swift +// + +import Combine +import Foundation +import os +import RTSCore +import SwiftUI + +@MainActor +class ChannelVideoViewModel: ObservableObject { + @Published var showStatsView: Bool + @Published var isFocused: Bool + @Published var statistics: StreamStatistics? + @Published var videoQualityList: [VideoQuality] + + let channel: Channel + private var cancellables = [AnyCancellable]() + + init(channel: Channel) { + self.channel = channel + + self.showStatsView = channel.showStatsView + self.isFocused = channel.isFocusedChannel + self.statistics = channel.streamStatistics + self.videoQualityList = channel.videoQualityList + + setListeners() + } +} + +private extension ChannelVideoViewModel { + func setListeners() { + channel.$showStatsView + .receive(on: DispatchQueue.main) + .sink { [weak self] show in + self?.showStatsView = show + } + .store(in: &cancellables) + + channel.$isFocusedChannel + .receive(on: DispatchQueue.main) + .sink { [weak self] isFocused in + self?.isFocused = isFocused + } + .store(in: &cancellables) + + channel.$streamStatistics + .receive(on: DispatchQueue.main) + .sink { [weak self] stats in + self?.statistics = stats + } + .store(in: &cancellables) + + channel.$videoQualityList + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.videoQualityList = list + } + .store(in: &cancellables) + } +} diff --git a/rts-viewer-tvos/RTSViewer/Views/ChannelView/ChannelView.swift b/rts-viewer-tvos/RTSViewer/Views/ChannelView/ChannelView.swift new file mode 100644 index 00000000..d4ed7576 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/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(unsourcedChannels: .constant([]), onClose: {})) + } diff --git a/rts-viewer-tvos/RTSViewer/Views/ChannelView/ChannelViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/ChannelView/ChannelViewModel.swift new file mode 100644 index 00000000..7ad88c98 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/ChannelView/ChannelViewModel.swift @@ -0,0 +1,162 @@ +// +// 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 unsourcedChannels: [UnsourcedChannel]? + @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: [Channel] = [] + + enum State { + case loading + case success(channels: [Channel]) + case error(title: String, subtitle: String?, showLiveIndicator: Bool) + } + + init(unsourcedChannels: Binding<[UnsourcedChannel]?>, onClose: @escaping () -> Void) { + self._unsourcedChannels = unsourcedChannels + self.onClose = onClose + startObservers() + } + + @objc func viewStreams() { + guard let unsourcedChannels else { return } + for unsourcedChannel in unsourcedChannels { + viewStream(with: unsourcedChannel) + } + } + + func viewStream(with unsourcedChannel: UnsourcedChannel) { + Task(priority: .userInitiated) { + let subscriptionManager = unsourcedChannel.subscriptionManager + let configuration = SubscriptionConfiguration(subscribeAPI: unsourcedChannel.streamConfig.apiUrl) + _ = try await subscriptionManager.subscribe( + streamName: unsourcedChannel.streamConfig.streamName, + accountID: unsourcedChannel.streamConfig.accountId, + configuration: configuration + ) + } + } + + func endStream() { + Task(priority: .userInitiated) { [weak self] in + guard let self, + let unsourcedChannels else { return } + for unsourcedChannel in unsourcedChannels { + self.subscriptions.removeAll() + self.reconnectionTimer?.invalidate() + self.reconnectionTimer = nil + _ = try await unsourcedChannel.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 unsourcedChannels else { return } + + for unsourcedChannel in unsourcedChannels { + await unsourcedChannel.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 }) + let soundSources = Array(activeSources.filter { $0.audioTrack?.isActive == true }) + + guard !soundSources.isEmpty else { return } + await self.updateChannelWithSources(unsourcedChannel: unsourcedChannel, sources: soundSources) + + guard !Task.isCancelled else { return } + + case .disconnected: + await 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` + await 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): + await Self.logger.debug("🎰 Connection error - \(connectionError.status), \(connectionError.reason)") + + if await !self.isWebsocketConnected { + await self.scheduleReconnection() + } + } + } + } + } + .store(in: &subscriptions) + + await unsourcedChannel.subscriptionManager.$websocketState + .sink { websocketState in + switch websocketState { + case .connected: + self.isWebsocketConnected = true + default: + break + } + } + .store(in: &subscriptions) + } + } + } + // swiftlint:enable function_body_length cyclomatic_complexity + + // TODO: Should be reworked when we have streams with a single source with audio + func updateChannelWithSources(unsourcedChannel: UnsourcedChannel, sources: [StreamSource]) { + guard !sourcedChannels.contains(where: { $0.id == unsourcedChannel.id }), + sources.count > 0 else { return } + let sourcedChannel = Channel(unsourcedChannel: unsourcedChannel, source: sources[0]) + sourcedChannels.append(sourcedChannel) + + update(state: .success(channels: sourcedChannels)) + } +} diff --git a/rts-viewer-tvos/RTSViewer/Views/LandingView/LandingView.swift b/rts-viewer-tvos/RTSViewer/Views/LandingView/LandingView.swift new file mode 100644 index 00000000..511f03a1 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/LandingView/LandingView.swift @@ -0,0 +1,96 @@ +// +// 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(unsourcedChannels: $viewModel.unsourcedChannel) { + viewModel.isShowingChannelView = false + } + NavigationLink(destination: ChannelView(viewModel: channelViewModel), isActive: $viewModel.isShowingChannelView) { + EmptyView() + } + .hidden() + + VStack { + Spacer() + streamInputBox + + Spacer() + HStack(spacing: Layout.spacing6x) { + DolbyIOUIKit.Text( + text: "\(viewModel.appVersion)", + font: .avenirNextRegular( + withStyle: .caption, + size: FontSize.caption1 + ) + ) + + DolbyIOUIKit.Text( + text: "\(viewModel.sdkVersion)", + font: .avenirNextRegular( + withStyle: .caption, + size: FontSize.caption1 + ) + ) + } + + 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: {} + ) + }) + } + + @ViewBuilder var streamInputBox: some View { + let streamDetailInputViewModel = StreamDetailInputViewModel(streamName: $viewModel.streamName, + accountID: $viewModel.accountID, + channels: $viewModel.unsourcedChannel, + isShowingStreamingView: $viewModel.isShowingStreamingView, + isShowingChannelView: $viewModel.isShowingChannelView, + isShowingRecentStreams: $viewModel.isShowingRecentStreams) + StreamDetailInputBox(viewModel: streamDetailInputViewModel) + } +} + +struct StreamDetailInputView_Previews: PreviewProvider { + static var previews: some View { + LandingView() + } +} diff --git a/rts-viewer-tvos/RTSViewer/Views/LandingView/LandingViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/LandingView/LandingViewModel.swift new file mode 100644 index 00000000..a3d12d16 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/LandingView/LandingViewModel.swift @@ -0,0 +1,39 @@ +// +// LandingViewModel.swift +// + +import Combine +import Foundation +import MillicastSDK +import RTSCore + +@MainActor +final class LandingViewModel: ObservableObject { + @Published var streamName: String = "" + @Published var accountID: String = "" + @Published var unsourcedChannel: [UnsourcedChannel]? + + @Published var isShowingStreamingView: Bool = false + @Published var isShowingChannelView: Bool = false + @Published var isShowingRecentStreams: Bool = false + @Published var isShowingErrorAlert = false + @Published var isShowingClearStreamsAlert = false + + let sdkVersion = "SDK Version \(MCLogger.getVersion())" + var appVersion: String = "" + + private let streamDataManager: StreamDataManagerProtocol + + init(streamDataManager: StreamDataManagerProtocol = StreamDataManager.shared) { + self.streamDataManager = streamDataManager + + if let version = Bundle.main.releaseVersionNumber, + let build = Bundle.main.buildVersionNumber { + self.appVersion = "App Version \(version) \(build)" + } + } + + func clearAllStreams() { + streamDataManager.clearAllStreams() + } +} diff --git a/rts-viewer-tvos/RTSViewer/RecentStreamsView/RecentStreamsView.swift b/rts-viewer-tvos/RTSViewer/Views/RecentStreamsView/RecentStreamsView.swift similarity index 100% rename from rts-viewer-tvos/RTSViewer/RecentStreamsView/RecentStreamsView.swift rename to rts-viewer-tvos/RTSViewer/Views/RecentStreamsView/RecentStreamsView.swift diff --git a/rts-viewer-tvos/RTSViewer/RecentStreamsView/RecentStreamsViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/RecentStreamsView/RecentStreamsViewModel.swift similarity index 100% rename from rts-viewer-tvos/RTSViewer/RecentStreamsView/RecentStreamsViewModel.swift rename to rts-viewer-tvos/RTSViewer/Views/RecentStreamsView/RecentStreamsViewModel.swift diff --git a/rts-viewer-tvos/RTSViewer/Views/SettingsMulitChannelView/SettingsMultichannelView.swift b/rts-viewer-tvos/RTSViewer/Views/SettingsMulitChannelView/SettingsMultichannelView.swift new file mode 100644 index 00000000..aa0c0f72 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/SettingsMulitChannelView/SettingsMultichannelView.swift @@ -0,0 +1,153 @@ +// +// SettingsMultichannelView.swift +// + +import DolbyIOUIKit +import RTSCore +import SwiftUI + +struct SettingsMultichannelView: View { + @State private var showSimulcastView: Bool = false + @State private var showStatsView: Bool = false + @Binding private var showSettingsView: Bool + + @FocusState private var focus: FocusableField? + + private let viewModel: SettingsMultichannelViewModel + private let theme = ThemeManager.shared.theme + + private enum FocusableField: Hashable { + case simulcastSelection + case streamStatisticsToggle + } + + init( + viewModel: SettingsMultichannelViewModel, + showSettingsView: Binding, + showStatsView: Bool + ) { + self.viewModel = viewModel + self.showStatsView = showStatsView + self._showSettingsView = showSettingsView + } + + var body: some View { + VStack(alignment: .center) { + title + + simulcastSelectionView + .focused($focus, equals: .simulcastSelection) + + statsToggle + .focused($focus, equals: .streamStatisticsToggle) + + Spacer() + } + .padding(Layout.spacing3x) + .focusSection() + .frame(maxWidth: UIScreen.main.bounds.size.width / 3) + .background(Color(uiColor: UIColor.Neutral.neutral800)) + .transition(.move(edge: .trailing)) + .overlay(content: { + if showSimulcastView { + let channel = viewModel.channel + SimulcastView( + source: channel.source, + videoQualityList: channel.videoQualityList, + selectedVideoQuality: channel.selectedVideoQuality + ) { videoQuality in + showSimulcastView = false + viewModel.updateSelectedVideoQuality(with: videoQuality) + focus = .simulcastSelection + } + .onExitCommand { + if showSimulcastView { + showSimulcastView = false + focus = .simulcastSelection + } + } + } + }) + .onAppear { + focus = viewModel.channel.videoQualityList.isEmpty ? .streamStatisticsToggle : .simulcastSelection + } + .onExitCommand { + showSettingsView.toggle() + } + } + + private var title: some View { + HStack { + Text( + text: "stream.settings.label", + mode: .secondary, + fontAsset: .avenirNextBold( + size: FontSize.title3, + style: .title3 + ) + ) + .foregroundColor(.white) + + Spacer() + } + } + + private var simulcastSelectionView: some View { + Button(action: { + showSimulcastView = true + }, label: { + HStack(spacing: Layout.spacing0x) { + let iconColor = Color(uiColor: viewModel.channel.videoQualityList.isEmpty ? .tertiaryLabel : .secondaryLabel) + IconView(name: .simulcast, tintColor: iconColor) + + Spacer() + .frame(width: Layout.spacing2x) + + Text( + text: "stream.simulcast.label", + mode: .primary, + fontAsset: .avenirNextDemiBold( + size: FontSize.body, + style: .body + ) + ) + + Text( + text: "stream.simulcast.label", + mode: viewModel.channel.videoQualityList.isEmpty ? .tertiary : .primary, + fontAsset: .avenirNextDemiBold( + size: FontSize.body, + style: .body + ) + ) + + Spacer() + + Text(viewModel.channel.selectedVideoQuality.displayText) + .font(theme[.avenirNextRegular(size: FontSize.body, style: .body)]) + .foregroundStyle(viewModel.channel.videoQualityList.isEmpty ? .tertiary : .secondary) + + IconView(name: .chevronRight, tintColor: iconColor) + } + .frame(height: Layout.spacing10x) + }) + .disabled(viewModel.channel.videoQualityList.isEmpty) + } + + private var statsToggle: some View { + Toggle(isOn: $showStatsView, label: { + HStack(spacing: Layout.spacing2x) { + IconView(name: .info, tintColor: Color(uiColor: .secondaryLabel)) + Text(text: "stream.media-stats.label", mode: .primary, fontAsset: .avenirNextDemiBold( + size: FontSize.body, + style: .body + )) + } + .frame(height: Layout.spacing10x) + }) + .onChange(of: showStatsView, perform: { show in + viewModel.shouldShowStatsView(showStats: show) + }) + .font(theme[.avenirNextRegular(size: FontSize.body, style: .body)]) + } +} diff --git a/rts-viewer-tvos/RTSViewer/Views/SettingsMulitChannelView/SettingsMultichannelViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/SettingsMulitChannelView/SettingsMultichannelViewModel.swift new file mode 100644 index 00000000..b6b67031 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/SettingsMulitChannelView/SettingsMultichannelViewModel.swift @@ -0,0 +1,23 @@ +// +// SettingsMultichannelViewModel.swift +// + +import Foundation +import MillicastSDK +import RTSCore + +final class SettingsMultichannelViewModel: ObservableObject { + @Published var channel: Channel + + init(channel: Channel) { + self.channel = channel + } + + func updateSelectedVideoQuality(with quality: VideoQuality) { + channel.enableVideo(with: quality) + } + + func shouldShowStatsView(showStats: Bool) { + channel.shouldShowStatsView(showStats: showStats) + } +} diff --git a/rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputBox.swift b/rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputBox.swift new file mode 100644 index 00000000..72aa2d94 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputBox.swift @@ -0,0 +1,110 @@ +// +// 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() + } + } + + Button( + action: { + viewModel.playFromConfig() + }, + text: "stream-detail-input.play-from-config.button" + ) + } + 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/Views/StreamDetailInputView/StreamDetailInputView.swift similarity index 100% rename from rts-viewer-tvos/RTSViewer/StreamDetailInputView/StreamDetailInputView.swift rename to rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputView.swift diff --git a/rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputViewModel.swift new file mode 100644 index 00000000..1193a720 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/StreamDetailInputView/StreamDetailInputViewModel.swift @@ -0,0 +1,117 @@ +// +// StreamDetailInputViewModel.swift +// + +import Combine +import Foundation +import MillicastSDK +import RTSCore +import SwiftUI + +@MainActor +final class StreamDetailInputViewModel: ObservableObject { + @Published private(set) var hasSavedStreams: Bool = false + @Binding var streamName: String + @Binding var accountID: String + @Binding private var channels: [UnsourcedChannel]? + @Binding var isShowingStreamingView: Bool + @Binding private var isShowingChannelView: Bool + @Published var isShowingRecentStreams: Bool = false + @Published var isShowingErrorAlert = false + @Published var isShowingClearStreamsAlert = false + @Published var streamDetails: [StreamDetail] = [] { + didSet { + hasSavedStreams = !streamDetails.isEmpty + } + } + + let sdkVersion = "SDK Version \(MCLogger.getVersion())" + var appVersion: String = "" + private let streamDataManager: StreamDataManagerProtocol + private var subscriptions: [AnyCancellable] = [] + + init( + streamName: Binding, + accountID: Binding, + channels: Binding<[UnsourcedChannel]?>, + isShowingStreamingView: Binding, + isShowingChannelView: Binding, + isShowingRecentStreams: Binding, + streamDataManager: StreamDataManagerProtocol = StreamDataManager.shared + ) { + self._streamName = streamName + self._accountID = accountID + self._channels = channels + self._isShowingStreamingView = isShowingStreamingView + self._isShowingChannelView = isShowingChannelView + self.streamDataManager = streamDataManager + + streamDataManager.streamDetailsSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] streamDetails in + self?.streamDetails = streamDetails + } + .store(in: &subscriptions) + } + + func playStream() { + Task { + guard checkIfCredentialsAreValid(streamName: streamName, accountID: accountID) else { + isShowingErrorAlert = true + return + } + + saveStream(streamName: streamName, accountID: accountID) + isShowingStreamingView = true + } + } + + func playFromConfig() { + var confirmedChannels = [UnsourcedChannel]() + let streamConfigs = getStreamConfigArray() + for (index, config) in streamConfigs.enumerated() { + guard let channel = setupChannel(for: config) else { return } + confirmedChannels.append(channel) + } + + guard !confirmedChannels.isEmpty else { return } + channels = confirmedChannels + isShowingChannelView = true + } +} + +private extension StreamDetailInputViewModel { + func checkIfCredentialsAreValid(streamName: String, accountID: String) -> Bool { + return streamName.count > 0 && accountID.count > 0 + } + + func saveStream(streamName: String, accountID: String) { + streamDataManager.saveStream(streamName, accountID: accountID) + } + + // TODO: Temporary until we have an online config to pull from + func getStreamConfigArray() -> [StreamConfig] { + let config1 = StreamConfig(apiUrl: "https://director.millicast.com/api/director/subscribe", + streamName: "multiview", + accountId: "k9Mwad") + + let config2 = StreamConfig(apiUrl: "https://director.millicast.com/api/director/subscribe", + streamName: "game", + accountId: "7csQUs") + + let config3 = StreamConfig(apiUrl: "https://director.millicast.com/api/director/subscribe", + streamName: "multiview", + accountId: "k9Mwad") + + let config4 = StreamConfig(apiUrl: "https://director.millicast.com/api/director/subscribe", + streamName: "game", + accountId: "7csQUs") + return [config1, config2, config3, config4] + } + + func setupChannel(for config: StreamConfig) -> UnsourcedChannel? { + let subscriptionManager = SubscriptionManager() + return UnsourcedChannel(streamConfig: config, + subscriptionManager: subscriptionManager) + } +} diff --git a/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift b/rts-viewer-tvos/RTSViewer/Views/StreamingView/StreamingView.swift similarity index 98% rename from rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift rename to rts-viewer-tvos/RTSViewer/Views/StreamingView/StreamingView.swift index 07572a18..0882e5a2 100644 --- a/rts-viewer-tvos/RTSViewer/StreamingView/StreamingView.swift +++ b/rts-viewer-tvos/RTSViewer/Views/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/StreamingView/StreamingViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/StreamingView/StreamingViewModel.swift similarity index 100% rename from rts-viewer-tvos/RTSViewer/StreamingView/StreamingViewModel.swift rename to rts-viewer-tvos/RTSViewer/Views/StreamingView/StreamingViewModel.swift diff --git a/rts-viewer-tvos/RTSViewer/Views/VideoRenderView/VideoRendererView.swift b/rts-viewer-tvos/RTSViewer/Views/VideoRenderView/VideoRendererView.swift new file mode 100644 index 00000000..b3f65d9e --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/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, + rendererRegistry: RendererRegistry, + 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, + rendererRegistry: rendererRegistry + ) + 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/Views/VideoRenderView/VideoRendererViewModel.swift b/rts-viewer-tvos/RTSViewer/Views/VideoRenderView/VideoRendererViewModel.swift new file mode 100644 index 00000000..bb9ad9a4 --- /dev/null +++ b/rts-viewer-tvos/RTSViewer/Views/VideoRenderView/VideoRendererViewModel.swift @@ -0,0 +1,100 @@ +// +// VideoRendererViewModel.swift +// + +import Combine +import Foundation +import MillicastSDK +import os +import RTSCore + +@MainActor +final class VideoRendererViewModel: ObservableObject { + 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 rendererRegistry: RendererRegistry + + private let subscriptionManager: SubscriptionManager + private var subscriptions: [AnyCancellable] = [] + + private enum Constants { + static let defaultVideoTileSize = CGSize(width: 533, height: 300) + } + + init( + source: StreamSource, + isSelectedVideoSource: Bool, + isSelectedAudioSource: Bool, + showSourceLabel: Bool, + showAudioIndicator: Bool, + maxWidth: CGFloat, + maxHeight: CGFloat, + preferredVideoQuality: VideoQuality, + subscriptionManager: SubscriptionManager, + rendererRegistry: RendererRegistry + ) { + 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.rendererRegistry = rendererRegistry + } + + var videoSize: CGSize { + let size = 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 { + 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 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 + } + } +} diff --git a/rts-viewer-tvos/fastlane/.env.default b/rts-viewer-tvos/fastlane/.env.default index 72e6113f..9be7dae5 100644 --- a/rts-viewer-tvos/fastlane/.env.default +++ b/rts-viewer-tvos/fastlane/.env.default @@ -1,5 +1,5 @@ // .env.default -XCODE_SELECT_PATH = /Applications/Xcode_15.2.app +XCODE_SELECT_PATH = /Applications/Xcode_15.4.app // // Temp paths - Build artifacts