diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 2d10a942a..f15d59799 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -91,10 +91,12 @@ 33CAC1226DFB8B5D8447D286 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; }; 344AF4CBB6D8786214878642 /* NavigationRouterStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9D5F812E5AD6DC786DBC9B /* NavigationRouterStoreProtocol.swift */; }; 34966D4C1C2C6D37FE3F7F50 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD2D50A7EAA4FC78417730E /* SettingsCoordinator.swift */; }; + 34C258B9CDFC07F7D9BD00E8 /* VideoPlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8482665982D664BDDA644F /* VideoPlayerViewModelTests.swift */; }; 352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */; }; 3588F34D05B4D731A73214C6 /* BugReportScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED59F9EFF273BFA2055FFDF /* BugReportScreen.swift */; }; 35C57543D245E82CBFE15DF0 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227AC5D71A4CE43512062243 /* URL.swift */; }; 35E975CFDA60E05362A7CF79 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 1222DB76B917EB8A55365BA5 /* target.yml */; }; + 3603946B7A65EAE18FF5AB63 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749C42DE5951C0695312EBFD /* FileCache.swift */; }; 368C8758FCD079E6AAA18C2C /* NoticeRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */; }; 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; }; 36C10EDEDC0466E3A9D63132 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B5B19A10D3F7C2BC5315DF /* VideoRoomTimelineItem.swift */; }; @@ -119,6 +121,8 @@ 462813B93C39DF93B1249403 /* RoundedToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFABDF2E19D349DAAAC18C65 /* RoundedToastView.swift */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; 4669804D0369FBED4E8625D1 /* ToastViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4470B8CB654B097D807AA713 /* ToastViewPresenter.swift */; }; + 46F8817A235DC41228128BE7 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92B7BF5D0705F3CB70E7B2D7 /* VideoPlayerViewModel.swift */; }; + 485A7A97076C7D19104BDC1D /* VideoPlayerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBE603A7EB2C93E81BA6415 /* VideoPlayerModels.swift */; }; 490E606044B18985055FF690 /* SettingsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E29F98CF0E960689A410E3 /* SettingsUITests.swift */; }; 492274DA6691EE985C2FCCAA /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 1BCD21310B997A6837B854D6 /* GZIP */; }; 49E9B99CB6A275C7744351F0 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D58333B377888012740101 /* LoginViewModel.swift */; }; @@ -155,6 +159,7 @@ 63C9AF0FB8278AF1C0388A0C /* TemplateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB10E673916D2B8D21FD197 /* TemplateModels.swift */; }; 64F43D7390DA2A0AFD6BA911 /* VideoRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1941C8817E6B6971BA4415F5 /* VideoRoomTimelineView.swift */; }; 64FF5CB4E35971255872E1BB /* AuthenticationServiceProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0CB536D1C3CC15AA740CC6 /* AuthenticationServiceProxyProtocol.swift */; }; + 656427D3C59554E03ECD898E /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41C2348F84A80F682E3A68D0 /* VideoPlayerCoordinator.swift */; }; 663E198678778F7426A9B27D /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FAFE1C2149E6AC8156ED2B /* Collection.swift */; }; 6647430A45B4A8E692909A8F /* EmoteRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77C060C2ACC4CB7336A29E7 /* EmoteRoomTimelineItem.swift */; }; 67C05C50AD734283374605E3 /* MatrixEntityRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */; }; @@ -201,6 +206,7 @@ 7FA4227B2BAAA71560252866 /* UserIndicatorDismissal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D1532B5D9FB0C8461A1453 /* UserIndicatorDismissal.swift */; }; 7FED310F6AB7A70CBFB7C8A3 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C483956FA3D665E3842E319A /* SettingsScreen.swift */; }; 8024BE37156FF0A95A7A3465 /* AnalyticsPromptUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF11DD57D9FACF2A757AB024 /* AnalyticsPromptUITests.swift */; }; + 80997E933A5B2C0868D80B45 /* VideoPlayerViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6410F8C03DC4AA46991A6B02 /* VideoPlayerViewModelProtocol.swift */; }; 80D00A7C62AAB44F54725C43 /* PermalinkBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */; }; 80E04BE80A89A78FBB4863BB /* UserIndicatorViewPresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 193FB285430D3956B6E61E4D /* UserIndicatorViewPresentable.swift */; }; 83E5054739949181CA981193 /* LoginCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD667C4BB98CF4F3FE2CE3B0 /* LoginCoordinator.swift */; }; @@ -217,6 +223,7 @@ 8B807DC963D1D4155A241BCC /* UserSessionFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F9E67AAB66638C69626866C /* UserSessionFlowCoordinator.swift */; }; 8BBD3AA589DEE02A1B0923B2 /* NoticeRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F49CDE349C490D617332770 /* NoticeRoomTimelineItem.swift */; }; 8CC12086CBF91A7E10CDC205 /* HomeScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */; }; + 8D332A24CD23B4216E33EC5C /* VideoPlayerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447A6399BC5EDE7AF7713267 /* VideoPlayerScreen.swift */; }; 8D3E1FADD78E72504DE0E402 /* UserAgentBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */; }; 8D9F646387DF656EF91EE4CB /* RoomMessageFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F37AB24AF5A006521D38D1 /* RoomMessageFactoryProtocol.swift */; }; 8F2FAA98457750D9D664136F /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 7731767AE437BA3BD2CC14A8 /* Sentry */; }; @@ -349,6 +356,8 @@ EA65360A0EC026DD83AC0CF5 /* AuthenticationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA5F386C7701C129398945 /* AuthenticationCoordinator.swift */; }; EC280623A42904341363EAAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 886A0A498FA01E8EDD451D05 /* Sentry */; }; EC4C31963E755EEC77BD778C /* AnalyticsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B362E695A7103C11F64B185 /* AnalyticsSettings.swift */; }; + ECDDA9CD291E6F4100FC93E6 /* FileCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECDDA9CC291E6F4100FC93E6 /* FileCacheTests.swift */; }; + ECDDA9D3292233B600FC93E6 /* Swipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECDDA9D2292233B600FC93E6 /* Swipe.swift */; }; EE4F5601356228FF72FC56B6 /* MockClientProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40F48279322E504153AB0D /* MockClientProxy.swift */; }; EE8491AD81F47DF3C192497B /* DecorationTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */; }; EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */; }; @@ -482,6 +491,7 @@ 2CF9FE7E0CF9F40D1509E63A /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = bg; path = bg.lproj/Localizable.stringsdict; sourceTree = ""; }; 2D256FEE2F1AF1E51D39B622 /* LoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginTests.swift; sourceTree = ""; }; 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProxy.swift; sourceTree = ""; }; + 2D8482665982D664BDDA644F /* VideoPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModelTests.swift; sourceTree = ""; }; 2EEB64CC6F3DF5B68736A6B4 /* AlertInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertInfo.swift; sourceTree = ""; }; 2F1B28C596DE541DA0AFD16C /* lo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = lo; path = lo.lproj/Localizable.stringsdict; sourceTree = ""; }; 304FFD608DB6E612075AB1B4 /* WeakDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionary.swift; sourceTree = ""; }; @@ -520,9 +530,11 @@ 3FEE631F3A4AFDC6652DD9DA /* RoomTimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactory.swift; sourceTree = ""; }; 40B21E611DADDEF00307E7AC /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 4112D04077F6709C5CA0A13E /* FullscreenLoadingViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenLoadingViewPresenter.swift; sourceTree = ""; }; + 41C2348F84A80F682E3A68D0 /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = ""; }; 422724361B6555364C43281E /* RoomHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomHeaderView.swift; sourceTree = ""; }; 434522ED2BDED08759048077 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 4470B8CB654B097D807AA713 /* ToastViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewPresenter.swift; sourceTree = ""; }; + 447A6399BC5EDE7AF7713267 /* VideoPlayerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerScreen.swift; sourceTree = ""; }; 4488F5F92A64A137665C96CD /* pa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pa; path = pa.lproj/Localizable.strings; sourceTree = ""; }; 44AEEE13AC1BF303AE48CBF8 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = ""; }; 44D8C8431416EB8DFEC7E235 /* ApplicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationTests.swift; sourceTree = ""; }; @@ -562,7 +574,6 @@ 541542F5AC323709D8563458 /* AnalyticsPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPrompt.swift; sourceTree = ""; }; 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; 54E438DBCBDC7A41B95DDDD9 /* MXLogObjcWrapper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MXLogObjcWrapper.m; sourceTree = ""; }; - 551DAED7F623AA5366E79927 /* repository */ = {isa = PBXFileReference; lastKnownFileType = folder; name = repository; path = .; sourceTree = SOURCE_ROOT; }; 55BC11560C8A2598964FFA4C /* bs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bs; path = bs.lproj/Localizable.strings; sourceTree = ""; }; 55D7187F6B0C0A651AC3DFFA /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = in; path = in.lproj/Localizable.strings; sourceTree = ""; }; 55F30E764BED111C81739844 /* SoftLogoutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutUITests.swift; sourceTree = ""; }; @@ -589,6 +600,7 @@ 6235E1CE00A6D989D7DB6D47 /* RectangleToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RectangleToastView.swift; sourceTree = ""; }; 624244C398804ADC885239AA /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineView.swift; sourceTree = ""; }; + 6410F8C03DC4AA46991A6B02 /* VideoPlayerViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModelProtocol.swift; sourceTree = ""; }; 653610CB5F9776EAAAB98155 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Localizable.stringsdict; sourceTree = ""; }; 65C2B80DD0BF6F10BB5FA922 /* MockAuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthenticationServiceProxy.swift; sourceTree = ""; }; 6654859746B0BE9611459391 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = cs; path = cs.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -618,6 +630,7 @@ 72D03D36422177EF01905D20 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderProtocol.swift; sourceTree = ""; }; 73FC861755C6388F62B9280A /* Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; }; + 749C42DE5951C0695312EBFD /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = ""; }; 752DEC02D93AFF46BC13313A /* NavigationRouterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouterType.swift; sourceTree = ""; }; 799A3A11C434296ED28F87C8 /* iw */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = iw; path = iw.lproj/Localizable.strings; sourceTree = ""; }; 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModelTests.swift; sourceTree = ""; }; @@ -652,17 +665,18 @@ 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; - 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = ""; }; + 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = ""; }; 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 9010EE0CC913D095887EF36E /* OIDCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCService.swift; sourceTree = ""; }; 90733775209F4D4D366A268F /* RootRouterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootRouterType.swift; sourceTree = ""; }; 9238D3A3A00F45E841FE4EFF /* DebugScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugScreen.swift; sourceTree = ""; }; + 92B7BF5D0705F3CB70E7B2D7 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTask.swift; sourceTree = ""; }; 9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = ""; }; 938BD1FCD9E6FF3FCFA7AB4C /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-CN"; path = "zh-CN.lproj/Localizable.stringsdict"; sourceTree = ""; }; @@ -750,6 +764,7 @@ BA7B2E9CC5DC3B76ADC35A43 /* AnalyticsPromptCheckmarkItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptCheckmarkItem.swift; sourceTree = ""; }; BB3073CCD77D906B330BC1D6 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = ""; }; BC9B05D6B293A039EB963CA7 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = ""; }; + BCBE603A7EB2C93E81BA6415 /* VideoPlayerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModels.swift; sourceTree = ""; }; BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerTextField.swift; sourceTree = ""; }; BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreProtocol.swift; sourceTree = ""; }; BEE6BF9BA63FF42F8AF6EEEA /* sr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = sr; path = sr.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -793,6 +808,7 @@ D0ADFDC712027931F2216668 /* WeakKeyDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakKeyDictionary.swift; sourceTree = ""; }; D1A9CCCF53495CF3D7B19FCE /* MockSessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionVerificationControllerProxy.swift; sourceTree = ""; }; D29EBCBFEC6FD0941749404D /* NavigationRouterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouterStore.swift; sourceTree = ""; }; + D31DC8105C6233E5FFD9B84C /* element-x-ios */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "element-x-ios"; path = .; sourceTree = SOURCE_ROOT; }; D33116993D54FADC0C721C1F /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenCoordinator.swift; sourceTree = ""; }; @@ -828,6 +844,8 @@ E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = ""; }; EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilderTests.swift; sourceTree = ""; }; EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; + ECDDA9CC291E6F4100FC93E6 /* FileCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCacheTests.swift; sourceTree = ""; }; + ECDDA9D2292233B600FC93E6 /* Swipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Swipe.swift; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; @@ -955,6 +973,7 @@ 0787F81684E503024BD0C051 /* Services */ = { isa = PBXGroup; children = ( + ECDDA9C9291E5EE800FC93E6 /* Cache */, 4BF8D11D9ED15CFC373D0119 /* Analytics */, AAFDD509929A0CCF8BCE51EB /* Authentication */, EBBEB5471737E9D116DF4738 /* Background */, @@ -1155,6 +1174,7 @@ A40C19719687984FD9478FBE /* Task.swift */, 287FC98AF2664EAD79C0D902 /* UIDevice.swift */, 227AC5D71A4CE43512062243 /* URL.swift */, + ECDDA9D2292233B600FC93E6 /* Swipe.swift */, ); path = Extensions; sourceTree = ""; @@ -1379,6 +1399,8 @@ 7B5CF94E124616FD89424B73 /* SplashScreenViewModelTests.swift */, 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */, EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */, + 2D8482665982D664BDDA644F /* VideoPlayerViewModelTests.swift */, + ECDDA9CC291E6F4100FC93E6 /* FileCacheTests.swift */, AF552BB969DC98A4BB8CF8D5 /* UserIndicators */, ); path = Sources; @@ -1520,7 +1542,7 @@ 9413F680ECDFB2B0DDB0DEF2 /* Packages */ = { isa = PBXGroup; children = ( - 551DAED7F623AA5366E79927 /* repository */, + D31DC8105C6233E5FFD9B84C /* element-x-ios */, ); name = Packages; sourceTree = SOURCE_ROOT; @@ -1608,6 +1630,14 @@ path = View; sourceTree = ""; }; + A253B36CAD2059B6D8C130CD /* View */ = { + isa = PBXGroup; + children = ( + 447A6399BC5EDE7AF7713267 /* VideoPlayerScreen.swift */, + ); + path = View; + sourceTree = ""; + }; A312471EA62EFB0FD94E60DC /* Style */ = { isa = PBXGroup; children = ( @@ -1807,6 +1837,18 @@ path = Vendor; sourceTree = ""; }; + D3E07C2F92EC8C5659601744 /* VideoPlayer */ = { + isa = PBXGroup; + children = ( + 41C2348F84A80F682E3A68D0 /* VideoPlayerCoordinator.swift */, + BCBE603A7EB2C93E81BA6415 /* VideoPlayerModels.swift */, + 92B7BF5D0705F3CB70E7B2D7 /* VideoPlayerViewModel.swift */, + 6410F8C03DC4AA46991A6B02 /* VideoPlayerViewModelProtocol.swift */, + A253B36CAD2059B6D8C130CD /* View */, + ); + path = VideoPlayer; + sourceTree = ""; + }; D958761758AA1110476DE6A3 /* SessionVerification */ = { isa = PBXGroup; children = ( @@ -1847,6 +1889,7 @@ E74CD7681375AD2EAA34D66B /* Authentication */, 4009BE2E791C16AC6EE39A7E /* BugReport */, B53CA9BECD3F97805E1432D0 /* HomeScreen */, + D3E07C2F92EC8C5659601744 /* VideoPlayer */, 679E9837ECA8D6776079D16E /* RoomScreen */, D958761758AA1110476DE6A3 /* SessionVerification */, 70B74A432C241E56A7ACE610 /* Settings */, @@ -1903,6 +1946,14 @@ path = Background; sourceTree = ""; }; + ECDDA9C9291E5EE800FC93E6 /* Cache */ = { + isa = PBXGroup; + children = ( + 749C42DE5951C0695312EBFD /* FileCache.swift */, + ); + path = Cache; + sourceTree = ""; + }; F8474EB69289112888B65518 /* UserIndicators */ = { isa = PBXGroup; children = ( @@ -2354,11 +2405,13 @@ 9C45CE85325CD591DADBC4CA /* ElementXTests.swift in Sources */, F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */, 0B1F80C2BF7D223159FBA82C /* ImageAnonymizerTests.swift in Sources */, + ECDDA9CD291E6F4100FC93E6 /* FileCacheTests.swift in Sources */, EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */, 0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */, 149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */, 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */, 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */, + 34C258B9CDFC07F7D9BD00E8 /* VideoPlayerViewModelTests.swift in Sources */, 27E9263DA75E266690A37EB1 /* PermalinkBuilderTests.swift in Sources */, 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */, EA31DD9043B91ECB8E45A9A6 /* ScreenshotDetectorTests.swift in Sources */, @@ -2471,10 +2524,17 @@ BCEC41FB1F2BB663183863E4 /* LoginServerInfoSection.swift in Sources */, 49E9B99CB6A275C7744351F0 /* LoginViewModel.swift in Sources */, 2F30EFEB7BD39242D1AD96F3 /* LoginViewModelProtocol.swift in Sources */, + ECDDA9D3292233B600FC93E6 /* Swipe.swift in Sources */, B94368839BDB69172E28E245 /* MXLog.swift in Sources */, 2A90D9F91A836E30B7D78838 /* MXLogObjcWrapper.m in Sources */, BCC3EDB7AD0902797CB4BBC2 /* MXLogger.m in Sources */, 67C05C50AD734283374605E3 /* MatrixEntityRegex.swift in Sources */, + 3603946B7A65EAE18FF5AB63 /* FileCache.swift in Sources */, + 656427D3C59554E03ECD898E /* VideoPlayerCoordinator.swift in Sources */, + 485A7A97076C7D19104BDC1D /* VideoPlayerModels.swift in Sources */, + 8D332A24CD23B4216E33EC5C /* VideoPlayerScreen.swift in Sources */, + 46F8817A235DC41228128BE7 /* VideoPlayerViewModel.swift in Sources */, + 80997E933A5B2C0868D80B45 /* VideoPlayerViewModelProtocol.swift in Sources */, EA1E7949533E19C6D862680A /* MediaProvider.swift in Sources */, 7002C55A4C917F3715765127 /* MediaProviderProtocol.swift in Sources */, 62BBF5BE7B905222F0477FF2 /* MediaSource.swift in Sources */, diff --git a/ElementX/Sources/Other/Extensions/Swipe.swift b/ElementX/Sources/Other/Extensions/Swipe.swift new file mode 100644 index 000000000..2cbd86c43 --- /dev/null +++ b/ElementX/Sources/Other/Extensions/Swipe.swift @@ -0,0 +1,34 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +extension View { + func onSwipeGesture(minimumDistance: CGFloat, + up: @escaping (() -> Void) = { }, + down: @escaping (() -> Void) = { }, + left: @escaping (() -> Void) = { }, + right: @escaping (() -> Void) = { }) -> some View { + gesture(DragGesture(minimumDistance: minimumDistance, coordinateSpace: .local) + .onEnded { value in + if value.translation.width < 0 { left() } + if value.translation.width > 0 { right() } + if value.translation.height < 0 { up() } + if value.translation.height > 0 { down() } + }) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 4b13171ef..f80751371 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -17,6 +17,7 @@ import SwiftUI struct RoomScreenCoordinatorParameters { + let navigationRouter: NavigationRouterType let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol let roomName: String? @@ -31,6 +32,7 @@ final class RoomScreenCoordinator: Coordinator, Presentable { private let parameters: RoomScreenCoordinatorParameters private let roomScreenHostingController: UIViewController private var roomScreenViewModel: RoomScreenViewModelProtocol + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } // MARK: Public @@ -55,7 +57,17 @@ final class RoomScreenCoordinator: Coordinator, Presentable { // MARK: - Public - func start() { } + func start() { + MXLog.debug("Did start.") + roomScreenViewModel.callback = { [weak self] result in + guard let self else { return } + MXLog.debug("RoomScreenViewModel did complete with result: \(result).") + switch result { + case .displayVideo(let videoURL): + self.displayVideo(for: videoURL) + } + } + } func toPresentable() -> UIViewController { roomScreenHostingController @@ -64,4 +76,22 @@ final class RoomScreenCoordinator: Coordinator, Presentable { func stop() { roomScreenViewModel.stop() } + + // MARK: - Private + + private func displayVideo(for videoURL: URL) { + let params = VideoPlayerCoordinatorParameters(videoURL: videoURL) + let coordinator = VideoPlayerCoordinator(parameters: params) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self, let coordinator = coordinator else { return } + self.navigationRouter.popModule(animated: true) + self.remove(childCoordinator: coordinator) + } + + add(childCoordinator: coordinator) + coordinator.start() + navigationRouter.push(coordinator) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 6e4874821..ef20147af 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -17,7 +17,9 @@ import Foundation import UIKit -enum RoomScreenViewModelAction { } +enum RoomScreenViewModelAction { + case displayVideo(videoURL: URL) +} enum RoomScreenComposerMode: Equatable { case `default` @@ -29,6 +31,7 @@ enum RoomScreenViewAction { case loadPreviousPage case itemAppeared(id: String) case itemDisappeared(id: String) + case itemTapped(id: String) case linkClicked(url: URL) case sendMessage case sendReaction(key: String, eventID: String) @@ -42,6 +45,7 @@ struct RoomScreenViewState: BindableState { var roomAvatar: UIImage? var items: [RoomTimelineViewProvider] = [] var isBackPaginating = false + var showLoading = false var bindings: RoomScreenViewStateBindings var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)? diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 5ff71ad9d..0e92c97da 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -81,14 +81,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } // MARK: - Public + + var callback: ((RoomScreenViewModelAction) -> Void)? override func process(viewAction: RoomScreenViewAction) async { switch viewAction { case .loadPreviousPage: - guard !state.isBackPaginating else { - return - } - switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) { default: #warning("Treat errors") @@ -97,6 +95,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol await timelineController.processItemAppearance(id) case .itemDisappeared(let id): await timelineController.processItemDisappearance(id) + case .itemTapped(let id): + await itemTapped(with: id) case .linkClicked(let url): MXLog.warning("Link clicked: \(url)") case .sendMessage: @@ -118,6 +118,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } // MARK: - Private + + private func itemTapped(with itemId: String) async { + state.showLoading = true + let action = await timelineController.processItemTap(itemId) + + switch action { + case .displayVideo(let videoURL): + callback?(.displayVideo(videoURL: videoURL)) + case .none: + break + } + + state.showLoading = false + } private func buildTimelineViews() { let stateItems = timelineController.timelineItems.map { item in diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift index 042fd58d9..7503da91f 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift @@ -18,6 +18,7 @@ import Foundation @MainActor protocol RoomScreenViewModelProtocol { + var callback: ((RoomScreenViewModelAction) -> Void)? { get set } var context: RoomScreenViewModelType.Context { get } func stop() diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 66378f3d8..9b469f58a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -20,30 +20,41 @@ struct RoomScreen: View { @ObservedObject var context: RoomScreenViewModel.Context var body: some View { - VStack(spacing: 0.0) { - TimelineView() - .environmentObject(context) - - MessageComposer(text: $context.composerText, - focused: $context.composerFocused, - sendingDisabled: context.viewState.sendButtonDisabled, - type: context.viewState.composerMode) { - sendMessage() - } replyCancellationAction: { - context.send(viewAction: .cancelReply) - } editCancellationAction: { - context.send(viewAction: .cancelEdit) + ZStack { + VStack(spacing: 0.0) { + TimelineView() + .environmentObject(context) + + MessageComposer(text: $context.composerText, + focused: $context.composerFocused, + sendingDisabled: context.viewState.sendButtonDisabled, + type: context.viewState.composerMode) { + sendMessage() + } replyCancellationAction: { + context.send(viewAction: .cancelReply) + } editCancellationAction: { + context.send(viewAction: .cancelEdit) + } + .padding() } - .padding() - } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - RoomHeaderView(context: context) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + RoomHeaderView(context: context) + } + } + .alert(item: $context.alertInfo) { $0.alert } + .sheet(item: $context.debugInfo) { DebugScreen(info: $0) } + + if context.viewState.showLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.element.primaryContent) + .padding(16) + .background(Color.element.quinaryContent) + .cornerRadius(8) } } - .alert(item: $context.alertInfo) { $0.alert } - .sheet(item: $context.debugInfo) { DebugScreen(info: $0) } } private func sendMessage() { diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift index df21ff065..408b2eb7c 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift @@ -76,6 +76,9 @@ struct TimelineItemList: View { context.send(viewAction: .linkClicked(url: url)) return .systemAction }) + .onTapGesture { + context.send(viewAction: .itemTapped(id: item.id)) + } } } } @@ -173,6 +176,9 @@ struct TimelineItemList: View { } private func requestBackPagination() { + guard !context.viewState.isBackPaginating else { + return + } context.send(viewAction: .loadPreviousPage) } diff --git a/ElementX/Sources/Screens/VideoPlayer/VideoPlayerCoordinator.swift b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerCoordinator.swift new file mode 100644 index 000000000..f6adba40f --- /dev/null +++ b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerCoordinator.swift @@ -0,0 +1,94 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct VideoPlayerCoordinatorParameters { + let videoURL: URL +} + +enum VideoPlayerCoordinatorAction { + case cancel +} + +final class VideoPlayerCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: VideoPlayerCoordinatorParameters + private let videoPlayerHostingController: UIViewController + private var videoPlayerViewModel: VideoPlayerViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var activityIndicator: UserIndicator? + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((VideoPlayerCoordinatorAction) -> Void)? + + // MARK: - Setup + + init(parameters: VideoPlayerCoordinatorParameters) { + self.parameters = parameters + + let viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL) + let view = VideoPlayerScreen(context: viewModel.context) + videoPlayerViewModel = viewModel + videoPlayerHostingController = UIHostingController(rootView: view) + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: videoPlayerHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("Did start.") + videoPlayerViewModel.callback = { [weak self] action in + guard let self else { return } + MXLog.debug("VideoPlayerViewModel did complete with result: \(action).") + switch action { + case .cancel: + self.callback?(.cancel) + } + } + } + + func toPresentable() -> UIViewController { + videoPlayerHostingController + } + + func stop() { + stopLoading() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + /// - Parameters: + /// - label: The label to show on the indicator. + /// - isInteractionBlocking: Whether the indicator should block any user interaction. + private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) { + activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + activityIndicator = nil + } +} diff --git a/ElementX/Sources/Screens/VideoPlayer/VideoPlayerModels.swift b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerModels.swift new file mode 100644 index 000000000..abdb7dc51 --- /dev/null +++ b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerModels.swift @@ -0,0 +1,36 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: - Coordinator + +// MARK: View model + +enum VideoPlayerViewModelAction { + case cancel +} + +// MARK: View + +struct VideoPlayerViewState: BindableState { + var videoURL: URL + var autoplay: Bool +} + +enum VideoPlayerViewAction { + case cancel +} diff --git a/ElementX/Sources/Screens/VideoPlayer/VideoPlayerViewModel.swift b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerViewModel.swift new file mode 100644 index 000000000..0253a6bfa --- /dev/null +++ b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerViewModel.swift @@ -0,0 +1,45 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +typealias VideoPlayerViewModelType = StateStoreViewModel + +class VideoPlayerViewModel: VideoPlayerViewModelType, VideoPlayerViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var callback: ((VideoPlayerViewModelAction) -> Void)? + + // MARK: - Setup + + init(videoURL: URL, autoplay: Bool = true) { + super.init(initialViewState: VideoPlayerViewState(videoURL: videoURL, + autoplay: autoplay)) + } + + // MARK: - Public + + override func process(viewAction: VideoPlayerViewAction) async { + switch viewAction { + case .cancel: + callback?(.cancel) + } + } +} diff --git a/ElementX/Sources/Screens/VideoPlayer/VideoPlayerViewModelProtocol.swift b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerViewModelProtocol.swift new file mode 100644 index 000000000..8e0311d70 --- /dev/null +++ b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +@MainActor +protocol VideoPlayerViewModelProtocol { + var callback: ((VideoPlayerViewModelAction) -> Void)? { get set } + var context: VideoPlayerViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/VideoPlayer/View/VideoPlayerScreen.swift b/ElementX/Sources/Screens/VideoPlayer/View/VideoPlayerScreen.swift new file mode 100644 index 000000000..d0b688510 --- /dev/null +++ b/ElementX/Sources/Screens/VideoPlayer/View/VideoPlayerScreen.swift @@ -0,0 +1,68 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AVKit +import SwiftUI + +struct VideoPlayerScreen: View { + // MARK: Public + + @ObservedObject var context: VideoPlayerViewModel.Context + + // MARK: Views + + var body: some View { + VideoPlayer(player: player()) + .ignoresSafeArea() + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + context.send(viewAction: .cancel) + } label: { + Image(systemName: "chevron.backward") + .foregroundColor(.white) + .fontWeight(.semibold) + } + } + } + .onSwipeGesture(minimumDistance: 3.0, right: { + context.send(viewAction: .cancel) + }) + } + + private func player() -> AVPlayer { + let player = AVPlayer(url: context.viewState.videoURL) + if context.viewState.autoplay { + player.play() + } + return player + } +} + +// MARK: - Previews + +struct VideoPlayer_Previews: PreviewProvider { + static var previews: some View { + Group { + let viewModel = VideoPlayerViewModel(videoURL: URL(staticString: "https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"), + autoplay: false) + VideoPlayerScreen(context: viewModel.context) + } + .tint(.element.accent) + } +} diff --git a/ElementX/Sources/Services/Cache/FileCache.swift b/ElementX/Sources/Services/Cache/FileCache.swift new file mode 100644 index 000000000..e7be4d5d6 --- /dev/null +++ b/ElementX/Sources/Services/Cache/FileCache.swift @@ -0,0 +1,89 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: - FileCacheProtocol + +protocol FileCacheProtocol { + func file(forKey key: String, fileExtension: String) -> URL? + func store(_ data: Data, with fileExtension: String, forKey key: String) throws -> URL + func remove(forKey key: String, fileExtension: String) throws + func removeAll() throws +} + +// MARK: - FileCache + +/// Implementation of `FileCacheProtocol` under `FileManager.default.temporaryDirectory`. +class FileCache { + private let fileManager = FileManager.default + private let folder: URL + + /// Default instance. Uses `FileCache` as the folder name. + static let `default` = FileCache(folderName: "FileCache") + + init(folderName: String) { + folder = fileManager.temporaryDirectory.appending(path: folderName, directoryHint: .isDirectory) + } + + // MARK: Private + + private func filePath(forKey key: String, fileExtension: String) -> URL { + folder.appending(path: key, directoryHint: .notDirectory).appendingPathExtension(fileExtension) + } + + private func folderExists() -> Bool { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: folder.path(), isDirectory: &isDirectory) else { + return false + } + return isDirectory.boolValue + } + + private func createFolderIfNeeded() throws { + guard !folderExists() else { + return + } + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + } +} + +// MARK: - FileCacheProtocol + +extension FileCache: FileCacheProtocol { + func file(forKey key: String, fileExtension: String) -> URL? { + let url = filePath(forKey: key, fileExtension: fileExtension) + return fileManager.isReadableFile(atPath: url.path()) ? url : nil + } + + func store(_ data: Data, with fileExtension: String, forKey key: String) throws -> URL { + try createFolderIfNeeded() + let url = filePath(forKey: key, fileExtension: fileExtension) + try data.write(to: url) + return url + } + + func remove(forKey key: String, fileExtension: String) throws { + try fileManager.removeItem(at: filePath(forKey: key, fileExtension: fileExtension)) + } + + func removeAll() throws { + guard folderExists() else { + return + } + try fileManager.removeItem(at: folder) + } +} diff --git a/ElementX/Sources/Services/Media/MediaProvider.swift b/ElementX/Sources/Services/Media/MediaProvider.swift index 71b723a52..d9c4577ff 100644 --- a/ElementX/Sources/Services/Media/MediaProvider.swift +++ b/ElementX/Sources/Services/Media/MediaProvider.swift @@ -20,13 +20,16 @@ import UIKit struct MediaProvider: MediaProviderProtocol { private let clientProxy: ClientProxyProtocol private let imageCache: Kingfisher.ImageCache + private let fileCache: FileCache private let backgroundTaskService: BackgroundTaskServiceProtocol init(clientProxy: ClientProxyProtocol, imageCache: Kingfisher.ImageCache, + fileCache: FileCache, backgroundTaskService: BackgroundTaskServiceProtocol) { self.clientProxy = clientProxy self.imageCache = imageCache + self.fileCache = fileCache self.backgroundTaskService = backgroundTaskService } @@ -88,6 +91,51 @@ struct MediaProvider: MediaProviderProtocol { return .failure(.failedRetrievingImage) } } + + func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL? { + guard let source else { + return nil + } + let cacheKey = fileCacheKeyForURLString(source.underlyingSource.url()) + return fileCache.file(forKey: cacheKey, fileExtension: fileExtension) + } + + @discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result { + if let url = fileFromSource(source, fileExtension: fileExtension) { + return .success(url) + } + + let loadFileBgTask = await backgroundTaskService.startBackgroundTask(withName: "LoadFile: \(source.url.hashValue)") + defer { + loadFileBgTask?.stop() + } + + let cacheKey = fileCacheKeyForURLString(source.url) + + do { + let data = try await clientProxy.loadMediaContentForSource(source.underlyingSource) + + let url = try fileCache.store(data, with: fileExtension, forKey: cacheKey) + return .success(url) + } catch { + MXLog.error("Failed retrieving file with error: \(error)") + return .failure(.failedRetrievingImage) + } + } + + func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL? { + guard let urlString else { + return nil + } + + return fileFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), + fileExtension: fileExtension) + } + + func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result { + await loadFileFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), + fileExtension: fileExtension) + } // MARK: - Private @@ -98,6 +146,13 @@ struct MediaProvider: MediaProviderProtocol { return urlString } } + + private func fileCacheKeyForURLString(_ urlString: String) -> String { + guard let component = urlString.split(separator: "/").last else { + return urlString + } + return String(component) + } } private extension ImageCache { diff --git a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift index bd9d3d84e..52768365e 100644 --- a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift +++ b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift @@ -19,6 +19,7 @@ import UIKit enum MediaProviderError: Error { case failedRetrievingImage + case failedRetrievingFile case invalidImageData } @@ -30,6 +31,14 @@ protocol MediaProviderProtocol { func imageFromURLString(_ urlString: String?, avatarSize: AvatarSize?) -> UIImage? @discardableResult func loadImageFromURLString(_ urlString: String, avatarSize: AvatarSize?) async -> Result + + func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL? + + @discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result + + func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL? + + @discardableResult func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result } extension MediaProviderProtocol { diff --git a/ElementX/Sources/Services/Media/MockMediaProvider.swift b/ElementX/Sources/Services/Media/MockMediaProvider.swift index 09cbde9b0..c92f6d85c 100644 --- a/ElementX/Sources/Services/Media/MockMediaProvider.swift +++ b/ElementX/Sources/Services/Media/MockMediaProvider.swift @@ -49,4 +49,20 @@ struct MockMediaProvider: MediaProviderProtocol { return .success(image) } + + func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL? { + nil + } + + @discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result { + .failure(.failedRetrievingFile) + } + + func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL? { + nil + } + + @discardableResult func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result { + .failure(.failedRetrievingFile) + } } diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift index 705ec4de4..dd46e6076 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift @@ -109,6 +109,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func processItemAppearance(_ itemId: String) async { } func processItemDisappearance(_ itemId: String) async { } + + func processItemTap(_ itemId: String) async -> RoomTimelineControllerAction { .none } func sendMessage(_ message: String) async { } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift index 318b585df..d6b322986 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift @@ -98,15 +98,36 @@ class RoomTimelineController: RoomTimelineControllerProtocol { switch timelineItem { case let item as ImageRoomTimelineItem: - await loadImageForImageTimelineItem(item) + await loadThumbnailForImageTimelineItem(item) case let item as VideoRoomTimelineItem: - await loadImageForVideoTimelineItem(item) + await loadThumbnailForVideoTimelineItem(item) default: break } } func processItemDisappearance(_ itemId: String) { } + + func processItemTap(_ itemId: String) async -> RoomTimelineControllerAction { + guard let timelineItem = timelineItems.first(where: { $0.id == itemId }) else { + return .none + } + + switch timelineItem { + case let item as VideoRoomTimelineItem: + await loadVideoForTimelineItem(item) + guard let index = timelineItems.firstIndex(where: { $0.id == itemId }), + let item = timelineItems[index] as? VideoRoomTimelineItem else { + return .none + } + if let videoURL = item.cachedVideoURL { + return .displayVideo(videoURL: videoURL) + } + return .none + default: + return .none + } + } func sendMessage(_ message: String) async { switch await timelineProvider.sendMessage(message) { @@ -244,7 +265,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return .middle } - private func loadImageForImageTimelineItem(_ timelineItem: ImageRoomTimelineItem) async { + private func loadThumbnailForImageTimelineItem(_ timelineItem: ImageRoomTimelineItem) async { if timelineItem.image != nil { return } @@ -268,7 +289,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } } - private func loadImageForVideoTimelineItem(_ timelineItem: VideoRoomTimelineItem) async { + private func loadThumbnailForVideoTimelineItem(_ timelineItem: VideoRoomTimelineItem) async { if timelineItem.image != nil { return } @@ -291,6 +312,32 @@ class RoomTimelineController: RoomTimelineControllerProtocol { break } } + + private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async { + if timelineItem.cachedVideoURL != nil { + // already cached + return + } + + guard let source = timelineItem.source else { + return + } + + // This is not great. We could better estimate file extension from the mimetype. + let fileExtension = String(timelineItem.text.split(separator: ".").last ?? "mp4") + switch await mediaProvider.loadFileFromSource(source, fileExtension: fileExtension) { + case .success(let fileURL): + guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), + var item = timelineItems[index] as? VideoRoomTimelineItem else { + return + } + + item.cachedVideoURL = fileURL + timelineItems[index] = item + case .failure: + break + } + } private func loadUserAvatarForTimelineItem(_ timelineItem: EventBasedTimelineItemProtocol) async { if timelineItem.shouldShowSenderDetails == false { diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift index 77421addf..e0b5b9051 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift @@ -24,6 +24,11 @@ enum RoomTimelineControllerCallback { case finishedBackPaginating } +enum RoomTimelineControllerAction { + case displayVideo(videoURL: URL) + case none +} + enum RoomTimelineControllerError: Error { case generic } @@ -38,6 +43,8 @@ protocol RoomTimelineControllerProtocol { func processItemAppearance(_ itemId: String) async func processItemDisappearance(_ itemId: String) async + + func processItemTap(_ itemId: String) async -> RoomTimelineControllerAction func paginateBackwards(_ count: UInt) async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift index 9741ff5ef..23ed01f6e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift @@ -33,6 +33,7 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa let source: MediaSource? let thumbnailSource: MediaSource? var image: UIImage? + var cachedVideoURL: URL? var width: CGFloat? var height: CGFloat? diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 2cbe3918f..4b38a4ad3 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -158,13 +158,15 @@ class UserSessionFlowCoordinator: Coordinator { mediaProvider: userSession.mediaProvider, roomProxy: roomProxy) - let parameters = RoomScreenCoordinatorParameters(timelineController: timelineController, + let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter, + timelineController: timelineController, mediaProvider: userSession.mediaProvider, roomName: roomProxy.displayName ?? roomProxy.name, roomAvatarUrl: roomProxy.avatarURL) let coordinator = RoomScreenCoordinator(parameters: parameters) add(childCoordinator: coordinator) + coordinator.start() navigationRouter.push(coordinator) { [weak self] in guard let self else { return } self.stateMachine.processEvent(.dismissedRoomScreen) diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index 8d835345c..f7a865beb 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -60,6 +60,7 @@ class UserSessionStore: UserSessionStoreProtocol { return .success(UserSession(clientProxy: clientProxy, mediaProvider: MediaProvider(clientProxy: clientProxy, imageCache: .onlyInMemory, + fileCache: .default, backgroundTaskService: backgroundTaskService))) case .failure(let error): MXLog.error("Failed restoring login with error: \(error)") @@ -78,6 +79,7 @@ class UserSessionStore: UserSessionStoreProtocol { return .success(UserSession(clientProxy: clientProxy, mediaProvider: MediaProvider(clientProxy: clientProxy, imageCache: .onlyInMemory, + fileCache: .default, backgroundTaskService: backgroundTaskService))) case .failure(let error): MXLog.error("Failed creating user session with error: \(error)") diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 1e3ca490a..87cf53f58 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -115,13 +115,15 @@ class MockScreen: Identifiable { case .splash: return SplashScreenCoordinator() case .roomPlainNoAvatar: - let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(), + let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter, + timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), roomName: "Some room name", roomAvatarUrl: nil) return RoomScreenCoordinator(parameters: parameters) case .roomEncryptedWithAvatar: - let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(), + let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter, + timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), roomName: "Some room name", roomAvatarUrl: "mock_url") diff --git a/UnitTests/Sources/FileCacheTests.swift b/UnitTests/Sources/FileCacheTests.swift new file mode 100644 index 000000000..31b4bca27 --- /dev/null +++ b/UnitTests/Sources/FileCacheTests.swift @@ -0,0 +1,78 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest + +@testable import ElementX + +class FileCacheTests: XCTestCase { + private var cache: FileCache! + + override func setUp() { + cache = .default + } + + override func tearDown() async throws { + try cache.removeAll() + } + + func testExistence() throws { + let data = Data(repeating: 1, count: 32) + let key = "some_key" + let fileExtension = "mp4" + + let url1 = try cache.store(data, with: fileExtension, forKey: key) + let url2 = cache.file(forKey: key, fileExtension: fileExtension) + + XCTAssertEqual(url1, url2) + } + + func testRemove() throws { + let data = Data(repeating: 1, count: 32) + let key = "some_key" + let fileExtension = "mp4" + + _ = try cache.store(data, with: fileExtension, forKey: key) + try cache.remove(forKey: key, fileExtension: fileExtension) + let url = cache.file(forKey: key, fileExtension: fileExtension) + + XCTAssertNil(url) + } + + func testRemoveAll() throws { + let data1 = Data(repeating: 1, count: 32) + let key1 = "some_key_1" + let fileExtension1 = "mp4" + + let data2 = Data(repeating: 1, count: 64) + let key2 = "some_key_2" + let fileExtension2 = "mp4" + + _ = try cache.store(data1, with: fileExtension1, forKey: key1) + _ = try cache.store(data2, with: fileExtension2, forKey: key2) + try cache.removeAll() + let url1 = cache.file(forKey: key1, fileExtension: fileExtension1) + let url2 = cache.file(forKey: key2, fileExtension: fileExtension2) + + XCTAssertNil(url1) + XCTAssertNil(url2) + } + + func testRemoveAllWhenEmpty() throws { + XCTAssertNoThrow(try cache.removeAll()) + } +} diff --git a/UnitTests/Sources/VideoPlayerViewModelTests.swift b/UnitTests/Sources/VideoPlayerViewModelTests.swift new file mode 100644 index 000000000..3cc58f800 --- /dev/null +++ b/UnitTests/Sources/VideoPlayerViewModelTests.swift @@ -0,0 +1,44 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import ElementX + +@MainActor +class VideoPlayerScreenViewModelTests: XCTestCase { + var viewModel: VideoPlayerViewModelProtocol! + var context: VideoPlayerViewModelType.Context! + + @MainActor override func setUpWithError() throws { + viewModel = VideoPlayerViewModel(videoURL: URL(staticString: "https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"), autoplay: true) + context = viewModel.context + } + + @MainActor func testCancel() async throws { + var correctResult = false + viewModel.callback = { result in + switch result { + case .cancel: + correctResult = true + } + } + + context.send(viewAction: .cancel) + await Task.yield() + XCTAssert(correctResult) + } +} diff --git a/changelog.d/238.feature b/changelog.d/238.feature new file mode 100644 index 000000000..e753ccaf1 --- /dev/null +++ b/changelog.d/238.feature @@ -0,0 +1 @@ +Timeline: Add playback support for video items.