Video playback (#308)

* Create media player screen

* Introduce `FileCache` to cache message attachments

* Add file loading functionality into the media provider

* Process tap action on timeline items

* Pass item taps to view model

* Navigate to media player on view model callback

* Commit project file

* Add changelog

* Rename media to video

* Add a loader when large videos being processed

* Add back button explicitly on video screen, fixes for light scheme

* Handle right swipe to dismiss video
This commit is contained in:
ismailgulek
2022-11-14 11:58:18 +03:00
committed by GitHub
parent 9959ac03d6
commit d44adafb3b
27 changed files with 820 additions and 39 deletions

View File

@@ -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 = "<group>"; };
2D256FEE2F1AF1E51D39B622 /* LoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginTests.swift; sourceTree = "<group>"; };
2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProxy.swift; sourceTree = "<group>"; };
2D8482665982D664BDDA644F /* VideoPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModelTests.swift; sourceTree = "<group>"; };
2EEB64CC6F3DF5B68736A6B4 /* AlertInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertInfo.swift; sourceTree = "<group>"; };
2F1B28C596DE541DA0AFD16C /* lo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = lo; path = lo.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
304FFD608DB6E612075AB1B4 /* WeakDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionary.swift; sourceTree = "<group>"; };
@@ -520,9 +530,11 @@
3FEE631F3A4AFDC6652DD9DA /* RoomTimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactory.swift; sourceTree = "<group>"; };
40B21E611DADDEF00307E7AC /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
4112D04077F6709C5CA0A13E /* FullscreenLoadingViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenLoadingViewPresenter.swift; sourceTree = "<group>"; };
41C2348F84A80F682E3A68D0 /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = "<group>"; };
422724361B6555364C43281E /* RoomHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomHeaderView.swift; sourceTree = "<group>"; };
434522ED2BDED08759048077 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = "<group>"; };
4470B8CB654B097D807AA713 /* ToastViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewPresenter.swift; sourceTree = "<group>"; };
447A6399BC5EDE7AF7713267 /* VideoPlayerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerScreen.swift; sourceTree = "<group>"; };
4488F5F92A64A137665C96CD /* pa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pa; path = pa.lproj/Localizable.strings; sourceTree = "<group>"; };
44AEEE13AC1BF303AE48CBF8 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = "<group>"; };
44D8C8431416EB8DFEC7E235 /* ApplicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationTests.swift; sourceTree = "<group>"; };
@@ -562,7 +574,6 @@
541542F5AC323709D8563458 /* AnalyticsPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPrompt.swift; sourceTree = "<group>"; };
5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
54E438DBCBDC7A41B95DDDD9 /* MXLogObjcWrapper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MXLogObjcWrapper.m; sourceTree = "<group>"; };
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 = "<group>"; };
55D7187F6B0C0A651AC3DFFA /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = in; path = in.lproj/Localizable.strings; sourceTree = "<group>"; };
55F30E764BED111C81739844 /* SoftLogoutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutUITests.swift; sourceTree = "<group>"; };
@@ -589,6 +600,7 @@
6235E1CE00A6D989D7DB6D47 /* RectangleToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RectangleToastView.swift; sourceTree = "<group>"; };
624244C398804ADC885239AA /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineView.swift; sourceTree = "<group>"; };
6410F8C03DC4AA46991A6B02 /* VideoPlayerViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModelProtocol.swift; sourceTree = "<group>"; };
653610CB5F9776EAAAB98155 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
65C2B80DD0BF6F10BB5FA922 /* MockAuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthenticationServiceProxy.swift; sourceTree = "<group>"; };
6654859746B0BE9611459391 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = cs; path = cs.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -618,6 +630,7 @@
72D03D36422177EF01905D20 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderProtocol.swift; sourceTree = "<group>"; };
73FC861755C6388F62B9280A /* Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
749C42DE5951C0695312EBFD /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = "<group>"; };
752DEC02D93AFF46BC13313A /* NavigationRouterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouterType.swift; sourceTree = "<group>"; };
799A3A11C434296ED28F87C8 /* iw */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = iw; path = iw.lproj/Localizable.strings; sourceTree = "<group>"; };
7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModelTests.swift; sourceTree = "<group>"; };
@@ -652,17 +665,18 @@
892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = "<group>"; };
8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = "<group>"; };
8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = "<group>"; };
8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = "<group>"; };
8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = "<group>"; };
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = "<group>"; };
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = "<group>"; };
8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = "<group>"; };
8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = "<group>"; };
9010EE0CC913D095887EF36E /* OIDCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCService.swift; sourceTree = "<group>"; };
90733775209F4D4D366A268F /* RootRouterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootRouterType.swift; sourceTree = "<group>"; };
9238D3A3A00F45E841FE4EFF /* DebugScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugScreen.swift; sourceTree = "<group>"; };
92B7BF5D0705F3CB70E7B2D7 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTask.swift; sourceTree = "<group>"; };
9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = "<group>"; };
938BD1FCD9E6FF3FCFA7AB4C /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-CN"; path = "zh-CN.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
@@ -750,6 +764,7 @@
BA7B2E9CC5DC3B76ADC35A43 /* AnalyticsPromptCheckmarkItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptCheckmarkItem.swift; sourceTree = "<group>"; };
BB3073CCD77D906B330BC1D6 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = "<group>"; };
BC9B05D6B293A039EB963CA7 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
BCBE603A7EB2C93E81BA6415 /* VideoPlayerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModels.swift; sourceTree = "<group>"; };
BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerTextField.swift; sourceTree = "<group>"; };
BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreProtocol.swift; sourceTree = "<group>"; };
BEE6BF9BA63FF42F8AF6EEEA /* sr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = sr; path = sr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -793,6 +808,7 @@
D0ADFDC712027931F2216668 /* WeakKeyDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakKeyDictionary.swift; sourceTree = "<group>"; };
D1A9CCCF53495CF3D7B19FCE /* MockSessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionVerificationControllerProxy.swift; sourceTree = "<group>"; };
D29EBCBFEC6FD0941749404D /* NavigationRouterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouterStore.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenCoordinator.swift; sourceTree = "<group>"; };
@@ -828,6 +844,8 @@
E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilderTests.swift; sourceTree = "<group>"; };
EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
ECDDA9CC291E6F4100FC93E6 /* FileCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCacheTests.swift; sourceTree = "<group>"; };
ECDDA9D2292233B600FC93E6 /* Swipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Swipe.swift; sourceTree = "<group>"; };
ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = "<group>"; };
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -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 = "<group>";
};
A253B36CAD2059B6D8C130CD /* View */ = {
isa = PBXGroup;
children = (
447A6399BC5EDE7AF7713267 /* VideoPlayerScreen.swift */,
);
path = View;
sourceTree = "<group>";
};
A312471EA62EFB0FD94E60DC /* Style */ = {
isa = PBXGroup;
children = (
@@ -1807,6 +1837,18 @@
path = Vendor;
sourceTree = "<group>";
};
D3E07C2F92EC8C5659601744 /* VideoPlayer */ = {
isa = PBXGroup;
children = (
41C2348F84A80F682E3A68D0 /* VideoPlayerCoordinator.swift */,
BCBE603A7EB2C93E81BA6415 /* VideoPlayerModels.swift */,
92B7BF5D0705F3CB70E7B2D7 /* VideoPlayerViewModel.swift */,
6410F8C03DC4AA46991A6B02 /* VideoPlayerViewModelProtocol.swift */,
A253B36CAD2059B6D8C130CD /* View */,
);
path = VideoPlayer;
sourceTree = "<group>";
};
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 = "<group>";
};
ECDDA9C9291E5EE800FC93E6 /* Cache */ = {
isa = PBXGroup;
children = (
749C42DE5951C0695312EBFD /* FileCache.swift */,
);
path = Cache;
sourceTree = "<group>";
};
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 */,

View File

@@ -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() }
})
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)?

View File

@@ -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

View File

@@ -18,6 +18,7 @@ import Foundation
@MainActor
protocol RoomScreenViewModelProtocol {
var callback: ((RoomScreenViewModelAction) -> Void)? { get set }
var context: RoomScreenViewModelType.Context { get }
func stop()

View File

@@ -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() {

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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<VideoPlayerViewState, VideoPlayerViewAction>
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)
}
}
}

View File

@@ -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 }
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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<URL, MediaProviderError> {
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<URL, MediaProviderError> {
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 {

View File

@@ -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<UIImage, MediaProviderError>
func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL?
@discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result<URL, MediaProviderError>
func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL?
@discardableResult func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result<URL, MediaProviderError>
}
extension MediaProviderProtocol {

View File

@@ -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<URL, MediaProviderError> {
.failure(.failedRetrievingFile)
}
func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL? {
nil
}
@discardableResult func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result<URL, MediaProviderError> {
.failure(.failedRetrievingFile)
}
}

View File

@@ -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 { }

View File

@@ -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 {

View File

@@ -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<Void, RoomTimelineControllerError>

View File

@@ -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?

View File

@@ -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)

View File

@@ -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)")

View File

@@ -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")

View File

@@ -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())
}
}

View File

@@ -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)
}
}

1
changelog.d/238.feature Normal file
View File

@@ -0,0 +1 @@
Timeline: Add playback support for video items.