From 82abd0aaf37895825b8fbc0fd16149182b84e7ba Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Wed, 4 Oct 2023 18:32:45 +0200 Subject: [PATCH] Add voice message playback from the timeline (#1844) --- ElementX.xcodeproj/project.pbxproj | 117 +++++++-- .../xcshareddata/swiftpm/Package.resolved | 27 +++ .../images/media/Contents.json | 6 + .../media/media-pause.imageset/Contents.json | 16 ++ .../media-pause.imageset/media-pause.svg | 3 + .../media/media-play.imageset/Contents.json | 16 ++ .../media/media-play.imageset/media-play.svg | 3 + .../RoomFlowCoordinator.swift | 8 +- ElementX/Sources/Generated/Assets.swift | 2 + .../Screens/RoomScreen/RoomScreenModels.swift | 4 +- .../RoomScreen/RoomScreenViewModel.swift | 12 +- .../Style/TimelineItemBubbledStylerView.swift | 30 +-- .../Style/TimelineItemPlainStylerView.swift | 30 +-- .../Services/AudioPlayer/AudioConverter.swift | 43 ++++ .../Services/AudioPlayer/AudioPlayer.swift | 223 ++++++++++++++++++ .../AudioPlayer/AudioPlayerProtocol.swift | 32 +++ .../AudioPlayer/AudioPlayerState.swift | 141 +++++++++++ .../MediaPlayer/MediaPlayerProtocol.swift | 39 +++ .../MediaPlayer/MediaPlayerProvider.swift | 47 ++++ .../MediaPlayerProviderProtocol.swift | 21 ++ .../MockRoomTimelineController.swift | 8 +- .../MockRoomTimelineControllerFactory.swift | 4 +- .../RoomTimelineController.swift | 89 +++++-- .../RoomTimelineControllerFactory.swift | 6 +- ...oomTimelineControllerFactoryProtocol.swift | 4 +- .../RoomTimelineControllerProtocol.swift | 2 +- ...ift => VoiceMessageRoomTimelineItem.swift} | 2 +- ...ift => VoiceMessageRoomPlaybackView.swift} | 146 ++++++------ .../VoiceMessageRoomTimelineView.swift | 99 ++++++++ .../VoiceRoomPlaybackViewState.swift | 43 ---- .../VoiceMessages/VoiceRoomTimelineView.swift | 93 -------- .../Messages/VoiceMessages/WaveformView.swift | 4 +- .../RoomTimelineItemFactory.swift | 26 +- .../TimelineItems/RoomTimelineItemView.swift | 2 +- .../RoomTimelineItemViewState.swift | 4 +- .../VoiceMessage/VoiceMessageCache.swift | 67 ++++++ .../VoiceMessageMediaManager.swift | 67 ++++++ .../VoiceMessageMediaManagerProtocol.swift | 21 ++ ElementX/SupportingFiles/target.yml | 1 + UITests/SupportingFiles/target.yml | 1 + .../test_voiceRoomPlaybackView.1.png | 4 +- .../test_voiceRoomTimelineView.Bubble.png | 4 +- .../test_voiceRoomTimelineView.Plain.png | 4 +- project.yml | 3 + 44 files changed, 1214 insertions(+), 310 deletions(-) create mode 100644 ElementX/Resources/Assets.xcassets/images/media/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/media/media-pause.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/media/media-pause.imageset/media-pause.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/media/media-play.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/media/media-play.imageset/media-play.svg create mode 100644 ElementX/Sources/Services/AudioPlayer/AudioConverter.swift create mode 100644 ElementX/Sources/Services/AudioPlayer/AudioPlayer.swift create mode 100644 ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift create mode 100644 ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift create mode 100644 ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift create mode 100644 ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift create mode 100644 ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift rename ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/{VoiceRoomTimelineItem.swift => VoiceMessageRoomTimelineItem.swift} (92%) rename ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/{VoiceRoomPlaybackView.swift => VoiceMessageRoomPlaybackView.swift} (57%) create mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomTimelineView.swift delete mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackViewState.swift delete mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift create mode 100644 ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift create mode 100644 ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift create mode 100644 ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManagerProtocol.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index cd025c99f..13004ad73 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 0180C44B997EDA8D21F883AC /* RoomNotificationSettingsCustomSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B746EFA112532A7B701FB914 /* RoomNotificationSettingsCustomSectionView.swift */; }; 020C530986D7B97631877FEF /* TimelineItemMacContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */; }; 020F7E70167FB2833266F2F0 /* AnalyticsSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D39D7F513A36C9C1951DB44C /* AnalyticsSettingsScreen.swift */; }; + 024E70451A7CD9E4E034D8A9 /* VoiceMessageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D529B976F8B2AA654D923422 /* VoiceMessageRoomTimelineItem.swift */; }; 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; }; 02F4FAE40AF63A1941FD3BBA /* NotificationCenterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B7F8EE25775DE2A305CBB5 /* NotificationCenterProtocol.swift */; }; 037006FB6DF1374F94E4058D /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */; }; @@ -110,7 +111,6 @@ 2355289BB0146231DD8AFFC0 /* AnalyticsMessageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2133A5FF0C14986E60326115 /* AnalyticsMessageType.swift */; }; 23701DE32ACD6FD40AA992C3 /* MediaUploadingPreprocessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */; }; 237FC70AA257B935F53316BA /* SessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */; }; - 242D4B5577D4D4494CF22FFA /* VoiceRoomPlaybackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7E0E34814F58A752DDC263 /* VoiceRoomPlaybackView.swift */; }; 245F7FE5961BD10C145A26E0 /* UITimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA689E792E679F5E3956F21 /* UITimelineView.swift */; }; 24A75F72EEB7561B82D726FD /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2141693488CE5446BB391964 /* Date.swift */; }; 24B7CD41342C143117ADA768 /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B1CC9AA154F4D5435BF60A /* Comparable.swift */; }; @@ -146,7 +146,6 @@ 2DA90E38FF4E696825810C1A /* WaitlistScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB08484CD5D77C9BF97AA78 /* WaitlistScreenUITests.swift */; }; 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */; }; 2E8C6672D0EE7D5B1BEDB8E2 /* ServerConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */; }; - 2E980266566100EF909BDFB0 /* VoiceRoomPlaybackViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D936D4F7AFD395BF14EC2D5A /* VoiceRoomPlaybackViewState.swift */; }; 2F1CF90A3460C153154427F0 /* RoomScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086B997409328F091EBA43CE /* RoomScreenUITests.swift */; }; 2F623DA1122140A987B34D08 /* NotificationSettingsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */; }; 2F66701B15657A87B4AC3A0A /* WaitlistScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CE2B7AD979BDEE09FEDB08 /* WaitlistScreenModels.swift */; }; @@ -178,6 +177,7 @@ 37D789F24199B32E3FD1AA7B /* FileRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */; }; 383055C6ABE5BE058CEE1DDB /* WelcomeScreenScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FE5EF0AFFE360C66420AAE /* WelcomeScreenScreenCoordinator.swift */; }; 38546A6010A2CF240EC9AF73 /* BindableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */; }; + 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40076C770A5FB83325252973 /* VoiceMessageMediaManager.swift */; }; 38896D54D6D675534E606195 /* RoomTimelineControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */; }; 388D39ED9FE1122EA6D76BF2 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BC84BA0AF11C2128D58ABD /* Common.swift */; }; 3982C505960006B341CFD0C6 /* UserDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */; }; @@ -196,6 +196,7 @@ 3EC698F80DDEEFA273857841 /* ArrayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 893777A4997BBDB68079D4F5 /* ArrayTests.swift */; }; 3ED2725734568F6B8CC87544 /* AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */; }; 3F2148F11164C7C5609984EB /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 2B788C81F6369D164ADEB917 /* GZIP */; }; + 3F327A62D233933F54F0F33A /* SwiftOGG in Frameworks */ = {isa = PBXBuildFile; productRef = 3FE40E79C36E7903121E6E3B /* SwiftOGG */; }; 3F70E237CE4C3FAB02FC227F /* NotificationConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C830A64609CBD152F06E0457 /* NotificationConstants.swift */; }; 401BB28CD6B7DD6B4E7863E7 /* ServerConfirmationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9342F5D6729627B6393AF853 /* ServerConfirmationScreenModels.swift */; }; 407DCE030E0F9B7C9861D38A /* LRUCache in Frameworks */ = {isa = PBXBuildFile; productRef = 1081D3630AAD3ACEDDEC3A98 /* LRUCache */; }; @@ -219,6 +220,7 @@ 44F0E1B576C7599DF8022071 /* Prefire in Frameworks */ = {isa = PBXBuildFile; productRef = 2629CF48B33643CD5F69C612 /* Prefire */; }; 4557192F5B15A8D9BB920232 /* AdvancedSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E492690C8B27A892C194CC4 /* AdvancedSettingsScreenCoordinator.swift */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; + 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */; }; 46A261AA898344A1F3C406B1 /* ReportContentScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */; }; 46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565F1B2B300597C616B37888 /* FullscreenDialog.swift */; }; 46D1E2940ED8CCBF62FE8854 /* CreatePollScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EA0F71A3A400A202E15318 /* CreatePollScreen.swift */; }; @@ -297,6 +299,7 @@ 5F06AD3C66884CE793AE6119 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; }; 5F28C9146694B381BB82E18C /* AnalyticsPromptScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B65A314DF40B6BBF775C2BC /* AnalyticsPromptScreenCoordinator.swift */; }; 5F5488FBC9CFEB6F433D74A4 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7109E709A7738E6BCC4553E6 /* Localizable.strings */; }; + 5F8E96263497FFB7D3254EB2 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1CD5EC6265A09315772DB7 /* AudioConverter.swift */; }; 60ED66E63A169E47489348A8 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 886A0A498FA01E8EDD451D05 /* Sentry */; }; 617624A97BDBB75ED3DD8156 /* RoomScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00C7A331B72C0F05C00392F /* RoomScreenViewModelProtocol.swift */; }; 6189B4ABD535CE526FA1107B /* StartChatViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */; }; @@ -328,7 +331,6 @@ 68184EF36396424FE19A727D /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; }; 6832733838C57A7D3FE8FEB5 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 78A5A8DE1E2B09C978C7F3B0 /* KeychainAccess */; }; 6860721DB3091BE08164C132 /* MapAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B48B7AD4908C5C374517B892 /* MapAssets.xcassets */; }; - 6888D47B4A5479CB9E0FB7F5 /* VoiceRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C9831E198AF030255D804D3 /* VoiceRoomTimelineItem.swift */; }; 68AC3C84E2B438036B174E30 /* EmoteRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */; }; 695825D20A761C678809345D /* MessageForwardingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */; }; 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */; }; @@ -368,6 +370,7 @@ 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; }; 754602A7B2AAD443C4228ED4 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 997C7385E1A07E061D7E2100 /* GZIP */; }; 755727E0B756430DFFEC4732 /* SessionVerificationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */; }; + 762DAF94846C7AC8550F1CC1 /* MediaPlayerProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */; }; 764AFCC225B044CF5F9B41E5 /* PaginationIndicatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EEA67A6796BDC2761619C5 /* PaginationIndicatorRoomTimelineView.swift */; }; 767D366C40F1311CFA333763 /* PillContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86376BEE425704AEE197CA54 /* PillContext.swift */; }; 76BA28216FBAF83B2D86A027 /* InvitesScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2A71915C1F075E403F559C /* InvitesScreenCell.swift */; }; @@ -432,6 +435,7 @@ 8691186F9B99BCDDB7CACDD8 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */; }; 872A6457DF573AF8CEAE927A /* LoginHomeserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9349F590E35CE514A71E6764 /* LoginHomeserver.swift */; }; 878070573C7BF19E735707B4 /* RoomTimelineItemProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */; }; + 87B4E59A4467F8EC75F82372 /* VoiceMessageRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70A50C41C5871B4DB905E7E /* VoiceMessageRoomTimelineView.swift */; }; 87CEDB8A0696F0D5AE2ABB28 /* test_audio.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = D5E26C54362206BBDD096D83 /* test_audio.mp3 */; }; 8810A2A30A68252EBB54EE05 /* HomeScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BC7CA1BC1041E93077BBA1 /* HomeScreenModels.swift */; }; 88356DE7F2AD243AB10C7B7A /* Signposter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */; }; @@ -509,6 +513,7 @@ 9DC5FB22B8F86C3B51E907C1 /* HomeScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6E4C37E9F0E53D3DF951AC /* HomeScreenUITests.swift */; }; 9DD5AA10E85137140FEA86A3 /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */; }; 9DD84E014ADFB2DD813022D5 /* RoomDetailsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */; }; + 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */; }; 9DE98D3EC47742A0F9F9EC3C /* MigrationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5685139D0B72BED3503EFCC /* MigrationScreen.swift */; }; 9DF3F6318A4402305F5EB869 /* AnalyticsPromptScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8002D0392A476D2758B291 /* AnalyticsPromptScreen.swift */; }; 9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; }; @@ -552,6 +557,8 @@ A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */; }; A8771F5975A82759FA5138AE /* RoomMemberDetailsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F19DBE940499D3E3DD405D8 /* RoomMemberDetailsScreenUITests.swift */; }; A896998A6784DB6F16E912F4 /* MockMediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB7D7DAAAF662DED9D02379 /* MockMediaLoader.swift */; }; + A93661C962B12942C08864B6 /* SwiftOGG in Frameworks */ = {isa = PBXBuildFile; productRef = 391D11F92DFC91666AA1503F /* SwiftOGG */; }; + A9482B967FC85DA611514D35 /* VoiceMessageRoomPlaybackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCD41CD67DB5DA0D436BFE9 /* VoiceMessageRoomPlaybackView.swift */; }; A969147E0EEE0E27EE226570 /* MediaUploadPreviewScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */; }; A975D60EA49F6AF73308809F /* RoomMembersListScreenMemberCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC03209FDE8CE0810617BFFF /* RoomMembersListScreenMemberCell.swift */; }; A9A5801D5EE3D4D91F6DDADB /* AnalyticsSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C2527813FDAE23E72A9063 /* AnalyticsSettingsScreenViewModelTests.swift */; }; @@ -637,6 +644,7 @@ C0DC02E2B91DC76A4D1A0E7F /* OnboardingScreenBackgroundImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3450F4C32D73532DBBC1A2 /* OnboardingScreenBackgroundImage.swift */; }; C11939FDC40716C4387275A4 /* NotificationSettingsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */; }; C13128AAA787A4C2CBE4EE82 /* MessageForwardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */; }; + C19085A284D54A166A64A86C /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */; }; C1910A16BDF131FECA77BE22 /* EmojiPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */; }; C1A5C386319835FB0C77736B /* ReportContentScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */; }; C1D0AB8222D7BAFC9AF9C8C0 /* MapLibreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */; }; @@ -681,6 +689,7 @@ CCBEC2100CAF2EEBE9DB4156 /* TemplateScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */; }; CCC3802A3C019A6FFAAA547A /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E65E613F057697A1A0BC03 /* NotificationViewController.swift */; }; CD0088B763CD970CF1CBF8CB /* DateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5E97E9615A158C76B2AB77 /* DateTests.swift */; }; + CD1C6943F42F29079E5E7511 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDDE503C9BAC82B661E4164 /* AudioPlayer.swift */; }; CD6A72B65D3B6076F4045C30 /* PHGPostHogConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */; }; CDCA8A559E098503DDE29477 /* AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */; }; CE1694C7BB93C3311524EF28 /* Untranslated.strings in Resources */ = {isa = PBXBuildFile; fileRef = D2F7194F440375338F8E2487 /* Untranslated.strings */; }; @@ -691,6 +700,7 @@ CF4044A8EED5C41BC0ED6ABE /* SoftLogoutScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */; }; CFEC53440C572CEEABC4A6A0 /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */; }; D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */; }; + D0550B8E0AE2C0CDBE52C88F /* MediaPlayerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE87C931165F5E201CACBB87 /* MediaPlayerProtocol.swift */; }; D12F440F7973F1489F61389D /* NotificationSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F64447FF544298A6A3BEF85 /* NotificationSettingsScreenModels.swift */; }; D181AC8FF236B7F91C0A8C28 /* MapTiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AA3F4B285570805CB0CCDD /* MapTiler.swift */; }; D1DFECA12FBF5346EAC4EE92 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A931ECBDC32FC90A6480751F /* WaveformView.swift */; }; @@ -792,10 +802,10 @@ EF0D0155DD104C7A41A2EB0E /* PlainMentionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */; }; EF5009AC03212227131C8AF2 /* RoomNotificationSettingsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */; }; EF7924005216B8189898F370 /* BackgroundTaskProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */; }; - EFE7E63F6702F6CB47A8CD6E /* VoiceRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75393B98D3841088D41D6E3 /* VoiceRoomTimelineView.swift */; }; F06CE9132855E81EBB6DDC32 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 940C605265DD82DA0C655E23 /* Kingfisher */; }; F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; F0A26CD502C3A5868353B0FA /* ServerConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */; }; + F0B196905CD23E3B4505CB7B /* AudioPlayerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A3590E8379BCED4D30D5C /* AudioPlayerProtocol.swift */; }; F0F82C3C848C865C3098AA52 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 21C83087604B154AA30E9A8F /* SnapshotTesting */; }; F118DD449066E594F63C697D /* RoomMemberProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5E17028C02DFA7DDA3931 /* RoomMemberProxyProtocol.swift */; }; F12F6BED7B6D7EE4BEE55039 /* PlainMentionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */; }; @@ -812,6 +822,7 @@ F54E2D6CAD96E1AC15BC526F /* MessageForwardingScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */; }; F5D2270B5021D521C0D22E11 /* FlowCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */; }; F656F92A63D3DC1978D79427 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 07FEEEDB11543A7DED420F04 /* Compound */; }; + F66BCCC825D6CA51724A94D0 /* MediaPlayerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */; }; F692D4AF571333C0D785725A /* MigrationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBC5E7C0F8337D2A46EB2DD /* MigrationScreenViewModelProtocol.swift */; }; F697284B9B5F2C00CFEA3B12 /* EmojiDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */; }; F6DFA23885980118AD7359C5 /* NotificationSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2389732B0E115A999A069083 /* NotificationSettingsScreenCoordinator.swift */; }; @@ -904,6 +915,7 @@ 03DD998E523D4EC93C7ED703 /* RoomNotificationSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModelTests.swift; sourceTree = ""; }; 045253F9967A535EE5B16691 /* Label.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Label.swift; sourceTree = ""; }; + 048A3590E8379BCED4D30D5C /* AudioPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerProtocol.swift; sourceTree = ""; }; 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.swift; sourceTree = ""; }; 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; 052B2F924572AFD70B5F500E /* StartChatScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenViewModel.swift; sourceTree = ""; }; @@ -1032,6 +1044,7 @@ 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringTests.swift; sourceTree = ""; }; 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenModels.swift; sourceTree = ""; }; 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProxy.swift; sourceTree = ""; }; + 2EDDE503C9BAC82B661E4164 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; 2EFE1922F39398ABFB36DF3F /* RoomDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsViewModelTests.swift; sourceTree = ""; }; 2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = ""; }; 303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModel.swift; sourceTree = ""; }; @@ -1067,7 +1080,7 @@ 3BFDAF6918BB096C44788FC9 /* RoomDetailsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenUITests.swift; sourceTree = ""; }; 3C1A3D524D63815B28FA4D62 /* EmojiCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCategory.swift; sourceTree = ""; }; 3C368CAB3063EF275357ECD4 /* LoginScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModel.swift; sourceTree = ""; }; - 3C9831E198AF030255D804D3 /* VoiceRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomTimelineItem.swift; sourceTree = ""; }; + 3CCD41CD67DB5DA0D436BFE9 /* VoiceMessageRoomPlaybackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRoomPlaybackView.swift; sourceTree = ""; }; 3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenModels.swift; sourceTree = ""; }; 3CFD5EB0B0EEA4549FB49784 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModelTests.swift; sourceTree = ""; }; @@ -1082,6 +1095,7 @@ 3F40F48279322E504153AB0D /* MockClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockClientProxy.swift; sourceTree = ""; }; 3F684BDD23ECEADB3053BA5A /* DeveloperOptionsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenUITests.swift; sourceTree = ""; }; 3FFDA99C98BE05F43A92343B /* test_pdf.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = test_pdf.pdf; sourceTree = ""; }; + 40076C770A5FB83325252973 /* VoiceMessageMediaManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManager.swift; sourceTree = ""; }; 40B21E611DADDEF00307E7AC /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 4132F882A984ED971338EE9D /* ReportContentScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenUITests.swift; sourceTree = ""; }; 4151163F666ED94FD959475A /* NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationName.swift; sourceTree = ""; }; @@ -1174,7 +1188,6 @@ 5CD0FAE9EA761DA175D31CC7 /* MigrationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreenModels.swift; sourceTree = ""; }; 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; 5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinatorUITests.swift; sourceTree = ""; }; - 5D7E0E34814F58A752DDC263 /* VoiceRoomPlaybackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomPlaybackView.swift; sourceTree = ""; }; 5D99730313BEBF08CDE81EE3 /* EmojiDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetection.swift; sourceTree = ""; }; 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = ""; }; @@ -1295,6 +1308,7 @@ 88410BD213FDF9B28E8B671F /* UserDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreen.swift; sourceTree = ""; }; 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlignedScrollView.swift; sourceTree = ""; }; 8896CDD20CA2D87EA3B848A1 /* RoomNotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreen.swift; sourceTree = ""; }; + 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerProtocol.swift; sourceTree = ""; }; 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; 893777A4997BBDB68079D4F5 /* ArrayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayTests.swift; sourceTree = ""; }; 8977176AB534AA41630395BC /* LegalInformationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -1430,6 +1444,7 @@ B63B69F9A2BC74DD40DC75C8 /* AdvancedSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenViewModel.swift; sourceTree = ""; }; B697816AF93DA06EC58C5D70 /* WaitlistScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenViewModelProtocol.swift; sourceTree = ""; }; B6E89E530A8E92EC44301CA1 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; + B70A50C41C5871B4DB905E7E /* VoiceMessageRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRoomTimelineView.swift; sourceTree = ""; }; B746EFA112532A7B701FB914 /* RoomNotificationSettingsCustomSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsCustomSectionView.swift; sourceTree = ""; }; B7AE92E7BFF71797BDE1D261 /* MapTilerStyleBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerStyleBuilder.swift; sourceTree = ""; }; B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsSignalling.swift; sourceTree = ""; }; @@ -1514,6 +1529,7 @@ CD700E035C85738EE4B97129 /* PerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = ""; }; CD95B3714F806AC9CF9A557B /* ComposerToolbarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarViewModel.swift; sourceTree = ""; }; CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = ""; }; + CE1CD5EC6265A09315772DB7 /* AudioConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverter.swift; sourceTree = ""; }; CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = ""; }; CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = ""; }; CF48AF076424DBC1615C74AD /* AuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxy.swift; sourceTree = ""; }; @@ -1535,6 +1551,7 @@ D3D455BC2423D911A62ACFB2 /* NSELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSELogger.swift; sourceTree = ""; }; D49B9785E3AD7D1C15A29F2F /* MediaSourceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceProxy.swift; sourceTree = ""; }; D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; + D529B976F8B2AA654D923422 /* VoiceMessageRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRoomTimelineItem.swift; sourceTree = ""; }; D53D6BB7E8E5EC031281872C /* OnboardingScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreenViewModelTests.swift; sourceTree = ""; }; D54E12B98252F6C527E31FEE /* MediaUploadPreviewScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModelProtocol.swift; sourceTree = ""; }; D5685139D0B72BED3503EFCC /* MigrationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreen.swift; sourceTree = ""; }; @@ -1549,9 +1566,9 @@ D8E057FB1F07A5C201C89061 /* MockServerSelectionScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServerSelectionScreenState.swift; sourceTree = ""; }; D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModel.swift; sourceTree = ""; }; D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = ""; }; - D936D4F7AFD395BF14EC2D5A /* VoiceRoomPlaybackViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomPlaybackViewState.swift; sourceTree = ""; }; DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenModels.swift; sourceTree = ""; }; DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenUITests.swift; sourceTree = ""; }; + DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCache.swift; sourceTree = ""; }; DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = ""; }; DBA8DC95C079805B0B56E8A9 /* SharedUserDefaultsKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsKeys.swift; sourceTree = ""; }; DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = ""; }; @@ -1586,10 +1603,10 @@ E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = ""; }; E6F5D66F158A6662F953733E /* NotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxy.swift; sourceTree = ""; }; E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerFactory.swift; sourceTree = ""; }; - E75393B98D3841088D41D6E3 /* VoiceRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomTimelineView.swift; sourceTree = ""; }; E80F9E9B93B6ECE9A937B1C6 /* FormRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRow.swift; sourceTree = ""; }; E8294DB9E95C0C0630418466 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; E8774CF614849664B5B3C2A1 /* UserSessionFlowCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorStateMachine.swift; sourceTree = ""; }; + E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProvider.swift; sourceTree = ""; }; E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = ""; }; E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = ""; }; E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFixtures.swift; sourceTree = ""; }; @@ -1629,6 +1646,7 @@ F5311C989EC15B4C2D699025 /* StaticLocationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLocationScreenViewModel.swift; sourceTree = ""; }; F57C8022B8A871A1DCD1750A /* UserIndicatorToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorToastView.swift; sourceTree = ""; }; F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxyMock.swift; sourceTree = ""; }; + F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProviderProtocol.swift; sourceTree = ""; }; F6D698BFD68B061350553930 /* WaitingDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitingDialog.swift; sourceTree = ""; }; F72EFC8C634469F9262659C7 /* NSItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProvider.swift; sourceTree = ""; }; F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedBodyText.swift; sourceTree = ""; }; @@ -1641,6 +1659,7 @@ F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = ""; }; F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = ""; }; FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelProtocol.swift; sourceTree = ""; }; + FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerState.swift; sourceTree = ""; }; FB0D6CB491777E7FC6B5BA12 /* CreateRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreen.swift; sourceTree = ""; }; FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = ""; }; FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreen.swift; sourceTree = ""; }; @@ -1648,6 +1667,7 @@ FC2D505742FDA21FCDC4C18A /* AudioRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoomTimelineView.swift; sourceTree = ""; }; FD1275D9CE0FFBA6E8E85426 /* UserIndicatorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorController.swift; sourceTree = ""; }; FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerTests.swift; sourceTree = ""; }; + FE87C931165F5E201CACBB87 /* MediaPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProtocol.swift; sourceTree = ""; }; FEC2E8E1B20BB2EA07B0B61E /* WelcomeScreenScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreenScreenViewModel.swift; sourceTree = ""; }; FEFEEE93B82937B2E86F92EB /* AnalyticsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsScreen.swift; sourceTree = ""; }; FFECCE59967018204876D0A5 /* LocationMarkerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationMarkerView.swift; sourceTree = ""; }; @@ -1721,6 +1741,7 @@ 492274DA6691EE985C2FCCAA /* Sentry in Frameworks */, F0F82C3C848C865C3098AA52 /* SnapshotTesting in Frameworks */, 3A64A93A651A3CB8774ADE8E /* Collections in Frameworks */, + 3F327A62D233933F54F0F33A /* SwiftOGG in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1749,12 +1770,23 @@ 36CD6E11B37396E14F032CB6 /* Emojibase in Frameworks */, A0D7E5BD0298A97DCBDCE40B /* WysiwygComposer in Frameworks */, 44F0E1B576C7599DF8022071 /* Prefire in Frameworks */, + A93661C962B12942C08864B6 /* SwiftOGG in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0121014DD16467736455AF16 /* VoiceMessage */ = { + isa = PBXGroup; + children = ( + DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */, + 40076C770A5FB83325252973 /* VoiceMessageMediaManager.swift */, + 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */, + ); + path = VoiceMessage; + sourceTree = ""; + }; 0210F4932B59277E2EEEF7BC /* RoomNotificationSettingsScreen */ = { isa = PBXGroup; children = ( @@ -1812,6 +1844,7 @@ isa = PBXGroup; children = ( 4BF8D11D9ED15CFC373D0119 /* Analytics */, + 984A887BA0294FE3B00CE9B1 /* AudioPlayer */, AAFDD509929A0CCF8BCE51EB /* Authentication */, EBBEB5471737E9D116DF4738 /* Background */, 0ED3F5C21537519389C07644 /* BugReport */, @@ -1820,6 +1853,7 @@ 39557ADF21345E18F3865B9E /* Emojis */, CA555F7C7CA382ACACF0D82B /* Keychain */, 79E560F5113ED25D172E550C /* Media */, + 6709362D60732DED2069AE0F /* MediaPlayer */, 6DE13A7AE6587B079F4049D7 /* Notification */, 114DC16B28140F885FD833E2 /* NotificationSettings */, 40E6246F03D1FE377BC5D963 /* Room */, @@ -1829,6 +1863,7 @@ FCDF06BDB123505F0334B4F9 /* Timeline */, E4E42F93A69AE52E6FAE9412 /* Users */, CBBF6127C313A5412E438BC6 /* UserSession */, + 0121014DD16467736455AF16 /* VoiceMessage */, ); path = Services; sourceTree = ""; @@ -2204,9 +2239,8 @@ 3A542DF1C3BB67D829DFDC40 /* VoiceMessages */ = { isa = PBXGroup; children = ( - 5D7E0E34814F58A752DDC263 /* VoiceRoomPlaybackView.swift */, - D936D4F7AFD395BF14EC2D5A /* VoiceRoomPlaybackViewState.swift */, - E75393B98D3841088D41D6E3 /* VoiceRoomTimelineView.swift */, + 3CCD41CD67DB5DA0D436BFE9 /* VoiceMessageRoomPlaybackView.swift */, + B70A50C41C5871B4DB905E7E /* VoiceMessageRoomTimelineView.swift */, A931ECBDC32FC90A6480751F /* WaveformView.swift */, ); path = VoiceMessages; @@ -2567,6 +2601,16 @@ path = AnalyticsPromptScreen; sourceTree = ""; }; + 6709362D60732DED2069AE0F /* MediaPlayer */ = { + isa = PBXGroup; + children = ( + FE87C931165F5E201CACBB87 /* MediaPlayerProtocol.swift */, + E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */, + F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */, + ); + path = MediaPlayer; + sourceTree = ""; + }; 6765932445C053E15E63C29A /* SupportingFiles */ = { isa = PBXGroup; children = ( @@ -2989,7 +3033,7 @@ 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */, F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */, 8C8616254EE40CA8BA5E9BC2 /* VideoRoomTimelineItemContent.swift */, - 3C9831E198AF030255D804D3 /* VoiceRoomTimelineItem.swift */, + D529B976F8B2AA654D923422 /* VoiceMessageRoomTimelineItem.swift */, 3A542DF1C3BB67D829DFDC40 /* VoiceMessages */, ); path = Messages; @@ -3200,6 +3244,17 @@ path = TimelineItems; sourceTree = ""; }; + 984A887BA0294FE3B00CE9B1 /* AudioPlayer */ = { + isa = PBXGroup; + children = ( + CE1CD5EC6265A09315772DB7 /* AudioConverter.swift */, + 2EDDE503C9BAC82B661E4164 /* AudioPlayer.swift */, + 048A3590E8379BCED4D30D5C /* AudioPlayerProtocol.swift */, + FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */, + ); + path = AudioPlayer; + sourceTree = ""; + }; 99B9B46F2D621380428E68F7 /* ElementX */ = { isa = PBXGroup; children = ( @@ -4014,6 +4069,7 @@ 67E7A6F388D3BF85767609D9 /* Sentry */, 21C83087604B154AA30E9A8F /* SnapshotTesting */, BA93CD75CCE486660C9040BD /* Collections */, + 3FE40E79C36E7903121E6E3B /* SwiftOGG */, ); productName = UITests; productReference = F506C6ADB1E1DA6638078E11 /* UITests.xctest */; @@ -4102,6 +4158,7 @@ C05729B1684C331F5FFE9232 /* Emojibase */, CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */, 2629CF48B33643CD5F69C612 /* Prefire */, + 391D11F92DFC91666AA1503F /* SwiftOGG */, ); productName = ElementX; productReference = 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */; @@ -4235,6 +4292,7 @@ 22E7BA2ED466B74739AB8567 /* XCRemoteSwiftPackageReference "Prefire" */, A08925A9D5E3770DEB9D8509 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, E9C4F3A12AA1F65C13A8C8EB /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, + FCB417752B308730C87E6BC8 /* XCRemoteSwiftPackageReference "swift-ogg" */, 6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */, 9A472EE0218FE7DCF5283429 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, EC6D0C817B1C21D9D096505A /* XCRemoteSwiftPackageReference "Version" */, @@ -4611,6 +4669,10 @@ D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */, 3ED2725734568F6B8CC87544 /* AttributedStringBuilder.swift in Sources */, A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */, + 5F8E96263497FFB7D3254EB2 /* AudioConverter.swift in Sources */, + CD1C6943F42F29079E5E7511 /* AudioPlayer.swift in Sources */, + F0B196905CD23E3B4505CB7B /* AudioPlayerProtocol.swift in Sources */, + C19085A284D54A166A64A86C /* AudioPlayerState.swift in Sources */, F8E725D42023ECA091349245 /* AudioRoomTimelineItem.swift in Sources */, 88F348E2CB14FF71CBBB665D /* AudioRoomTimelineItemContent.swift in Sources */, E62EC30B39354A391E32A126 /* AudioRoomTimelineView.swift in Sources */, @@ -4795,6 +4857,9 @@ 208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */, A2434D4DFB49A68E5CD0F53C /* MediaLoaderProtocol.swift in Sources */, 4E0D9E09B52CEC4C0E6211A8 /* MediaPickerScreenCoordinator.swift in Sources */, + D0550B8E0AE2C0CDBE52C88F /* MediaPlayerProtocol.swift in Sources */, + F66BCCC825D6CA51724A94D0 /* MediaPlayerProvider.swift in Sources */, + 762DAF94846C7AC8550F1CC1 /* MediaPlayerProviderProtocol.swift in Sources */, B6EC2148FA5443C9289BEEBA /* MediaProvider.swift in Sources */, 30CC1DB7CE357659C82AA115 /* MediaProviderProtocol.swift in Sources */, 5897A59DDBD3592282092223 /* MediaSourceProxy.swift in Sources */, @@ -5116,10 +5181,12 @@ 1A83DD22F3E6F76B13B6E2F9 /* VideoRoomTimelineItemContent.swift in Sources */, 64F43D7390DA2A0AFD6BA911 /* VideoRoomTimelineView.swift in Sources */, 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */, - 242D4B5577D4D4494CF22FFA /* VoiceRoomPlaybackView.swift in Sources */, - 2E980266566100EF909BDFB0 /* VoiceRoomPlaybackViewState.swift in Sources */, - 6888D47B4A5479CB9E0FB7F5 /* VoiceRoomTimelineItem.swift in Sources */, - EFE7E63F6702F6CB47A8CD6E /* VoiceRoomTimelineView.swift in Sources */, + 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */, + 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */, + 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */, + A9482B967FC85DA611514D35 /* VoiceMessageRoomPlaybackView.swift in Sources */, + 024E70451A7CD9E4E034D8A9 /* VoiceMessageRoomTimelineItem.swift in Sources */, + 87B4E59A4467F8EC75F82372 /* VoiceMessageRoomTimelineView.swift in Sources */, 6F2D5D4F2590310DFAE973E4 /* WaitingDialog.swift in Sources */, 9AFEE46B03B7E995B3E1A53D /* WaitlistScreen.swift in Sources */, 7C384A8E54A4B60A14CDE8E5 /* WaitlistScreenCoordinator.swift in Sources */, @@ -5924,6 +5991,14 @@ minimumVersion = 1.0.0; }; }; + FCB417752B308730C87E6BC8 /* XCRemoteSwiftPackageReference "swift-ogg" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/vector-im/swift-ogg"; + requirement = { + branch = 0.0.1; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -6017,6 +6092,16 @@ package = D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */; productName = DeviceKit; }; + 391D11F92DFC91666AA1503F /* SwiftOGG */ = { + isa = XCSwiftPackageProductDependency; + package = FCB417752B308730C87E6BC8 /* XCRemoteSwiftPackageReference "swift-ogg" */; + productName = SwiftOGG; + }; + 3FE40E79C36E7903121E6E3B /* SwiftOGG */ = { + isa = XCSwiftPackageProductDependency; + package = FCB417752B308730C87E6BC8 /* XCRemoteSwiftPackageReference "swift-ogg" */; + productName = SwiftOGG; + }; 4003BC24B24C9E63D3304177 /* DeviceKit */ = { isa = XCSwiftPackageProductDependency; package = D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a5dd455bb..ea550bd13 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -142,6 +142,24 @@ "version" : "2.14.1" } }, + { + "identity" : "ogg-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vector-im/ogg-swift.git", + "state" : { + "revision" : "9d82ed838404f10b607a1a1689f404563e9115c3", + "version" : "0.8.3" + } + }, + { + "identity" : "opus-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vector-im/opus-swift", + "state" : { + "revision" : "11f1887767cbc87c4b64b789ee830b779cc744cb", + "version" : "0.8.4" + } + }, { "identity" : "posthog-ios", "kind" : "remoteSourceControl", @@ -205,6 +223,15 @@ "version" : "1.0.2" } }, + { + "identity" : "swift-ogg", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vector-im/swift-ogg", + "state" : { + "branch" : "0.0.1", + "revision" : "e9a9e7601da662fd8b97d93781ff5c60b4becf88" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/ElementX/Resources/Assets.xcassets/images/media/Contents.json b/ElementX/Resources/Assets.xcassets/images/media/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/media/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/media/media-pause.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/media/media-pause.imageset/Contents.json new file mode 100644 index 000000000..b9b6e445c --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/media/media-pause.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "media-pause.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/media/media-pause.imageset/media-pause.svg b/ElementX/Resources/Assets.xcassets/images/media/media-pause.imageset/media-pause.svg new file mode 100644 index 000000000..35b4cc4a2 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/media/media-pause.imageset/media-pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/media/media-play.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/media/media-play.imageset/Contents.json new file mode 100644 index 000000000..fa9d8becd --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/media/media-play.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "media-play.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/media/media-play.imageset/media-play.svg b/ElementX/Resources/Assets.xcassets/images/media/media-play.imageset/media-play.svg new file mode 100644 index 000000000..d30429baa --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/media/media-play.imageset/media-play.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 1ef7915a8..52bc1ddf1 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -320,6 +320,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let userID = userSession.clientProxy.userID + let mediaPlayerProvider = MediaPlayerProvider(mediaProvider: userSession.mediaProvider) + let timelineItemFactory = RoomTimelineItemFactory(userID: userID, mediaProvider: userSession.mediaProvider, attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: appSettings.permalinkBaseURL, @@ -327,9 +329,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID), appSettings: appSettings) + let voiceMesssageMediaManager = VoiceMessageMediaManager(mediaProvider: userSession.mediaProvider) + let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, - mediaProvider: userSession.mediaProvider) + mediaProvider: userSession.mediaProvider, + mediaPlayerProvider: mediaPlayerProvider, + voiceMessageMediaManager: voiceMesssageMediaManager) self.timelineController = timelineController analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace) diff --git a/ElementX/Sources/Generated/Assets.swift b/ElementX/Sources/Generated/Assets.swift index 63ba6fd04..fd93f899b 100644 --- a/ElementX/Sources/Generated/Assets.swift +++ b/ElementX/Sources/Generated/Assets.swift @@ -63,6 +63,8 @@ internal enum Asset { internal static let locationPin = ImageAsset(name: "images/location-pin") internal static let locationPointerFull = ImageAsset(name: "images/location-pointer-full") internal static let locationPointer = ImageAsset(name: "images/location-pointer") + internal static let mediaPause = ImageAsset(name: "images/media-pause") + internal static let mediaPlay = ImageAsset(name: "images/media-play") internal static let addReaction = ImageAsset(name: "images/add-reaction") internal static let copy = ImageAsset(name: "images/copy") internal static let editOutline = ImageAsset(name: "images/edit-outline") diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 3a8caf89e..6d09637fc 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -109,8 +109,8 @@ struct RoomScreenViewState: BindableState { /// A closure providing the actions to show when long pressing on an item in the timeline. var timelineItemMenuActionProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> TimelineItemMenuActions?)? - /// A closure providing the associated audio playback view state for an item in the timeline. - var audioPlaybackViewStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState?)? + /// A closure providing the associated audio player state for an item in the timeline. + var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)? } struct RoomScreenViewStateBindings { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 8375ca837..96360c4cb 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -77,10 +77,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol return self.timelineItemMenuActionsForItemId(itemId) } - state.audioPlaybackViewStateProvider = { [weak self] itemId -> VoiceRoomPlaybackViewState? in - guard let self else { return nil } + state.audioPlayerStateProvider = { [weak self] itemId -> AudioPlayerState? in + guard let self else { + return nil + } - return self.audioPlaybackViewState(for: itemId) + return self.audioPlayerState(for: itemId) } buildTimelineViews() @@ -883,8 +885,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol // MARK: - Audio - private func audioPlaybackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? { - timelineController.playbackViewState(for: itemID) + private func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState { + timelineController.audioPlayerState(for: itemID) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index c2fe4610c..61fcff8ea 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -491,21 +491,21 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .text(.init(body: "Short"))))) - VoiceRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), - timestamp: "10:42", - isOutgoing: true, - isEditable: false, - canBeRepliedTo: true, - isThreaded: true, - sender: .init(id: ""), - content: .init(body: "audio.ogg", - duration: 100, - waveform: Waveform.mockWaveform, - source: nil, - contentType: nil), - replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), - contentType: .text(.init(body: "Short")))), - playbackViewState: VoiceRoomPlaybackViewState(duration: 10, waveform: Waveform.mockWaveform)) + VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "audio.ogg", + duration: 100, + waveform: Waveform.mockWaveform, + source: nil, + contentType: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short")))), + playerState: AudioPlayerState(duration: 10, waveform: Waveform.mockWaveform)) } .environmentObject(viewModel.context) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index d5255f0df..e9a784d99 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -218,21 +218,21 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview { geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .text(.init(body: "Short"))))) - VoiceRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), - timestamp: "10:42", - isOutgoing: true, - isEditable: false, - canBeRepliedTo: true, - isThreaded: true, - sender: .init(id: ""), - content: .init(body: "audio.ogg", - duration: 100, - waveform: Waveform.mockWaveform, - source: nil, - contentType: nil), - replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), - contentType: .text(.init(body: "Short")))), - playbackViewState: VoiceRoomPlaybackViewState(duration: 10, waveform: Waveform.mockWaveform)) + VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "audio.ogg", + duration: 100, + waveform: Waveform.mockWaveform, + source: nil, + contentType: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short")))), + playerState: AudioPlayerState(duration: 10, waveform: Waveform.mockWaveform)) } .environmentObject(viewModel.context) } diff --git a/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift b/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift new file mode 100644 index 000000000..23f1282fc --- /dev/null +++ b/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift @@ -0,0 +1,43 @@ +// +// Copyright 2023 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 AVFoundation +import Foundation +import SwiftOGG + +enum AudioConverterError: Error { + case conversionFailed(Error?) +} + +struct AudioConverter { + func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws { + do { + try OGGConverter.convertM4aFileToOpusOGG(src: sourceURL, dest: destinationURL) + } catch { + MXLog.error("failed to convert to OpusOgg: \(error)") + throw AudioConverterError.conversionFailed(error) + } + } + + func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL) throws { + do { + try OGGConverter.convertOpusOGGToM4aFile(src: sourceURL, dest: destinationURL) + } catch { + MXLog.error("failed to convert to MPEG4AAC: \(error)") + throw AudioConverterError.conversionFailed(error) + } + } +} diff --git a/ElementX/Sources/Services/AudioPlayer/AudioPlayer.swift b/ElementX/Sources/Services/AudioPlayer/AudioPlayer.swift new file mode 100644 index 000000000..59ea47861 --- /dev/null +++ b/ElementX/Sources/Services/AudioPlayer/AudioPlayer.swift @@ -0,0 +1,223 @@ +// +// Copyright 2023 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 AVFoundation +import Combine +import Foundation +import UIKit + +private enum InternalAudioPlayerState { + case none + case loading + case readyToPlay + case playing + case paused + case stopped + case finishedPlaying + case error(Error) +} + +enum AudioPlayerError: Error { + case genericError + case loadFileError +} + +class AudioPlayer: NSObject, AudioPlayerProtocol { + var mediaSource: MediaSourceProxy? + + private var playerItem: AVPlayerItem? + private var internalAudioPlayer: AVQueuePlayer? + + private var cancellables = Set() + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + private var internalState = InternalAudioPlayerState.none + + private var statusObserver: NSKeyValueObservation? + private var rateObserver: NSKeyValueObservation? + private var playToEndObserver: NSObjectProtocol? + private var appBackgroundObserver: NSObjectProtocol? + + private(set) var url: URL? + + var duration: TimeInterval { + abs(CMTimeGetSeconds(internalAudioPlayer?.currentItem?.duration ?? .zero)) + } + + var currentTime: TimeInterval { + let currentTime = abs(CMTimeGetSeconds(internalAudioPlayer?.currentTime() ?? .zero)) + return currentTime.isFinite ? currentTime : .zero + } + + var state: MediaPlayerState { + if case .loading = internalState { + return .loading + } + if case .stopped = internalState { + return .stopped + } + if case .playing = internalState { + return .playing + } + if case .paused = internalState { + return .paused + } + if case .error = internalState { + return .error + } + return .stopped + } + + private var isStopped = true + + deinit { + stop() + unloadContent() + } + + func load(mediaSource: MediaSourceProxy, using url: URL) { + unloadContent() + setInternalState(.loading) + self.mediaSource = mediaSource + self.url = url + playerItem = AVPlayerItem(url: url) + internalAudioPlayer = AVQueuePlayer(playerItem: playerItem) + addObservers() + } + + func play() { + isStopped = false + do { + try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + MXLog.error("Could not redirect audio playback to speakers.") + } + internalAudioPlayer?.play() + } + + func pause() { + guard case .playing = internalState else { return } + internalAudioPlayer?.pause() + } + + func stop() { + guard !isStopped else { return } + isStopped = true + internalAudioPlayer?.pause() + internalAudioPlayer?.seek(to: .zero) + } + + func seek(to progress: Double) async { + guard let internalAudioPlayer else { return } + let time = progress * duration + await internalAudioPlayer.seek(to: CMTime(seconds: time, preferredTimescale: 60000)) + } + + // MARK: - Private + + private func unloadContent() { + mediaSource = nil + url = nil + internalAudioPlayer?.replaceCurrentItem(with: nil) + internalAudioPlayer = nil + playerItem = nil + removeObservers() + } + + private func addObservers() { + guard let internalAudioPlayer, let playerItem else { + return + } + + statusObserver = playerItem.observe(\.status, options: [.old, .new]) { [weak self] _, _ in + guard let self else { return } + + switch playerItem.status { + case .failed: + self.setInternalState(.error(playerItem.error ?? AudioPlayerError.genericError)) + case .readyToPlay: + guard state == .loading else { return } + self.setInternalState(.readyToPlay) + default: + break + } + } + + rateObserver = internalAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] _, _ in + guard let self else { return } + + if internalAudioPlayer.rate == 0 { + if self.isStopped { + self.setInternalState(.stopped) + } else { + self.setInternalState(.paused) + } + } else { + self.setInternalState(.playing) + } + } + + NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime) + .sink { [weak self] _ in + guard let self else { return } + self.setInternalState(.finishedPlaying) + } + .store(in: &cancellables) + + // Pause playback uppon UIApplication.didBecomeActiveNotification notification + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .sink { [weak self] _ in + guard let self else { return } + self.pause() + } + .store(in: &cancellables) + } + + private func removeObservers() { + statusObserver?.invalidate() + rateObserver?.invalidate() + cancellables.removeAll() + } + + private func setInternalState(_ state: InternalAudioPlayerState) { + internalState = state + switch state { + case .none: + break + case .loading: + actionsSubject.send(.didStartLoading) + case .readyToPlay: + actionsSubject.send(.didFinishLoading) + play() + case .playing: + actionsSubject.send(.didStartPlaying) + case .paused: + actionsSubject.send(.didPausePlaying) + case .stopped: + actionsSubject.send(.didStopPlaying) + case .finishedPlaying: + actionsSubject.send(.didFinishPlaying) + unloadContent() + case .error(let error): + MXLog.error("audio player did fail. \(error)") + actionsSubject.send(.didFailWithError(error: error)) + } + } +} diff --git a/ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift b/ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift new file mode 100644 index 000000000..1bb249479 --- /dev/null +++ b/ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift @@ -0,0 +1,32 @@ +// +// Copyright 2023 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 Combine +import Foundation + +enum AudioPlayerAction { + case didStartLoading + case didFinishLoading + case didStartPlaying + case didPausePlaying + case didStopPlaying + case didFinishPlaying + case didFailWithError(error: Error) +} + +protocol AudioPlayerProtocol: MediaPlayerProtocol { + var actions: AnyPublisher { get } +} diff --git a/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift b/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift new file mode 100644 index 000000000..a5721ec49 --- /dev/null +++ b/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift @@ -0,0 +1,141 @@ +// +// Copyright 2023 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 Combine +import Foundation +import UIKit + +enum AudioPlayerPlaybackState { + case loading + case readyToPlay + case playing + case stopped + case error +} + +@MainActor +class AudioPlayerState: ObservableObject { + let duration: Double + let waveform: Waveform + @Published private(set) var playbackState: AudioPlayerPlaybackState + @Published private(set) var progress: Double + + private var audioPlayer: AudioPlayerProtocol? + private var cancellables: Set = [] + private var cancellableTimer: AnyCancellable? + + var isAttached: Bool { + audioPlayer != nil + } + + init(duration: Double, waveform: Waveform? = nil, progress: Double = 0.0) { + self.duration = duration + self.waveform = waveform ?? Waveform(data: []) + self.progress = progress + playbackState = .stopped + } + + func updateState(progress: Double) async { + let progress = max(0.0, min(progress, 1.0)) + self.progress = progress + if let audioPlayer { + await audioPlayer.seek(to: progress) + } + } + + func attachAudioPlayer(_ audioPlayer: AudioPlayerProtocol) { + if self.audioPlayer != nil { + detachAudioPlayer() + } + playbackState = .loading + self.audioPlayer = audioPlayer + subscribeToAudioPlayer(audioPlayer: audioPlayer) + } + + func detachAudioPlayer() { + guard audioPlayer != nil else { return } + audioPlayer?.stop() + stopPublishProgress() + cancellables = [] + audioPlayer = nil + playbackState = .stopped + } + + func reportError(_ error: Error) { + playbackState = .error + } + + // MARK: - Private + + private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) { + audioPlayer.actions + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + guard let self else { + return + } + self.handleAudioPlayerAction(action) + } + .store(in: &cancellables) + } + + private func handleAudioPlayerAction(_ action: AudioPlayerAction) { + switch action { + case .didStartLoading: + playbackState = .loading + case .didFinishLoading: + if let audioPlayer { + Task { + await restoreAudioPlayerState(audioPlayer: audioPlayer) + } + } + playbackState = .readyToPlay + case .didStartPlaying: + playbackState = .playing + startPublishProgress() + case .didPausePlaying, .didStopPlaying, .didFinishPlaying: + stopPublishProgress() + playbackState = .stopped + if case .didFinishPlaying = action { + progress = 0.0 + } + case .didFailWithError: + stopPublishProgress() + } + } + + private func startPublishProgress() { + cancellableTimer?.cancel() + + cancellableTimer = Timer.publish(every: 0.2, on: .main, in: .default) + .autoconnect() + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] _ in + guard let self else { return } + if let currentTime = self.audioPlayer?.currentTime, self.duration > 0 { + self.progress = currentTime / self.duration + } + }) + } + + private func stopPublishProgress() { + cancellableTimer?.cancel() + } + + private func restoreAudioPlayerState(audioPlayer: AudioPlayerProtocol) async { + await audioPlayer.seek(to: progress) + } +} diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift new file mode 100644 index 000000000..b961259ee --- /dev/null +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift @@ -0,0 +1,39 @@ +// +// Copyright 2023 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 + +enum MediaPlayerState { + case loading + case playing + case paused + case stopped + case error +} + +protocol MediaPlayerProtocol { + var mediaSource: MediaSourceProxy? { get } + + var currentTime: TimeInterval { get } + var url: URL? { get } + var state: MediaPlayerState { get } + + func load(mediaSource: MediaSourceProxy, using url: URL) + func play() + func pause() + func stop() + func seek(to progress: Double) async +} diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift new file mode 100644 index 000000000..0474cd57b --- /dev/null +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift @@ -0,0 +1,47 @@ +// +// Copyright 2023 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 + +class MediaPlayerProvider: MediaPlayerProviderProtocol { + private let mediaProvider: MediaProviderProtocol + private var audioPlayer: AudioPlayerProtocol? + + init(mediaProvider: MediaProviderProtocol) { + self.mediaProvider = mediaProvider + } + + deinit { + audioPlayer = nil + } + + func player(for mediaSource: MediaSourceProxy) -> MediaPlayerProtocol? { + guard let mimeType = mediaSource.mimeType else { + MXLog.error("Unknown mime type") + return nil + } + + if mimeType.starts(with: "audio/") { + if audioPlayer == nil { + audioPlayer = AudioPlayer() + } + return audioPlayer + } else { + MXLog.error("Unsupported media type: \(mediaSource.mimeType ?? "unknown")") + return nil + } + } +} diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift new file mode 100644 index 000000000..b4c6487b8 --- /dev/null +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift @@ -0,0 +1,21 @@ +// +// Copyright 2023 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 + +protocol MediaPlayerProviderProtocol { + func player(for mediaSource: MediaSourceProxy) async -> MediaPlayerProtocol? +} diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index 43a1b8dc7..3342c9564 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -82,10 +82,10 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func retryDecryption(for sessionID: String) async { } - func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? { - VoiceRoomPlaybackViewState(duration: 10.0, - waveform: nil, - progress: 0.0) + func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState { + AudioPlayerState(duration: 10.0, + waveform: nil, + progress: 0.0) } func playPauseAudio(for itemID: TimelineItemIdentifier) async { } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift index b4b89cd84..2bee5ea26 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift @@ -19,7 +19,9 @@ import Foundation struct MockRoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { func buildRoomTimelineController(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol { + mediaProvider: MediaProviderProtocol, + mediaPlayerProvider: MediaPlayerProviderProtocol, + voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol { let timelineController = MockRoomTimelineController() timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk return timelineController diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 7569ff10b..641e478ce 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -23,6 +23,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol { private let timelineProvider: RoomTimelineProviderProtocol private let timelineItemFactory: RoomTimelineItemFactoryProtocol private let mediaProvider: MediaProviderProtocol + private let mediaPlayerProvider: MediaPlayerProviderProtocol + private let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol private let appSettings: AppSettings private let serialDispatchQueue: DispatchQueue @@ -36,7 +38,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { let callbacks = PassthroughSubject() private(set) var timelineItems = [RoomTimelineItemProtocol]() - private var timelineAudioPlaybackViewStates = [TimelineItemIdentifier: VoiceRoomPlaybackViewState]() + private var timelineAudioPlayerStates = [TimelineItemIdentifier: AudioPlayerState]() var roomID: String { roomProxy.id @@ -45,11 +47,15 @@ class RoomTimelineController: RoomTimelineControllerProtocol { init(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol, + mediaPlayerProvider: MediaPlayerProviderProtocol, + voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, appSettings: AppSettings) { self.roomProxy = roomProxy timelineProvider = roomProxy.timelineProvider self.timelineItemFactory = timelineItemFactory self.mediaProvider = mediaProvider + self.mediaPlayerProvider = mediaPlayerProvider + self.voiceMessageMediaManager = voiceMessageMediaManager self.appSettings = appSettings serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility) @@ -222,32 +228,77 @@ class RoomTimelineController: RoomTimelineControllerProtocol { await roomProxy.retryDecryption(for: sessionID) } - func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? { + func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState { guard let timelineItem = timelineItems.firstUsingStableID(itemID) else { - MXLog.error("timelineItem not found") - return .none + fatalError("TimelineItem \(itemID) not found") } - switch timelineItem { - case let item as VoiceRoomTimelineItem: - if let playbackViewState = timelineAudioPlaybackViewStates[itemID] { - return playbackViewState + guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else { + fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead") + } + + if let playerState = timelineAudioPlayerStates[itemID] { + return playerState + } + let playerState = AudioPlayerState(duration: voiceMessageRoomTimelineItem.content.duration, + waveform: voiceMessageRoomTimelineItem.content.waveform) + timelineAudioPlayerStates[itemID] = playerState + return playerState + } + + func playPauseAudio(for itemID: TimelineItemIdentifier) async { + guard let timelineItem = timelineItems.firstUsingStableID(itemID) else { + fatalError("TimelineItem \(itemID) not found") + } + + guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else { + fatalError("Invalid TimelineItem type for itemID \(itemID) (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead") + } + + guard let source = voiceMessageRoomTimelineItem.content.source else { + MXLog.error("Cannot start voice message playback, source is not defined for itemID \(itemID)") + return + } + + guard let player = await mediaPlayerProvider.player(for: source) as? AudioPlayerProtocol else { + MXLog.error("Cannot play a voice message without an audio player") + return + } + + let playerState = audioPlayerState(for: itemID) + + guard player.mediaSource == source, player.state != .error else { + timelineAudioPlayerStates.forEach { itemID, playerState in + if itemID != timelineItem.id { + playerState.detachAudioPlayer() + } } - let playbackViewState = VoiceRoomPlaybackViewState(duration: item.content.duration, - waveform: item.content.waveform) - timelineAudioPlaybackViewStates[itemID] = playbackViewState - return playbackViewState - default: - return .none + playerState.attachAudioPlayer(player) + + // Load content + do { + let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil) + // Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded. + if playerState.isAttached { + player.load(mediaSource: source, using: url) + } + } catch { + MXLog.error("Failed to load voice message: \(error)") + playerState.reportError(error) + } + + return + } + + if player.state == .playing { + player.pause() + } else { + player.play() } } - func playPauseAudio(for itemID: TimelineItemIdentifier) async { } - func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { - Task { - timelineAudioPlaybackViewStates[itemID]?.updateState(progress: progress) - } + await timelineAudioPlayerStates[itemID]?.updateState(progress: progress) } // MARK: - Private diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift index 443666dd1..c097370f3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift @@ -19,10 +19,14 @@ import Foundation struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { func buildRoomTimelineController(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol { + mediaProvider: MediaProviderProtocol, + mediaPlayerProvider: MediaPlayerProviderProtocol, + voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol { RoomTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider, + mediaPlayerProvider: mediaPlayerProvider, + voiceMessageMediaManager: voiceMessageMediaManager, appSettings: ServiceLocator.shared.settings) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift index 471794545..17e7d0aa5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift @@ -20,5 +20,7 @@ import Foundation protocol RoomTimelineControllerFactoryProtocol { func buildRoomTimelineController(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol + mediaProvider: MediaProviderProtocol, + mediaPlayerProvider: MediaPlayerProviderProtocol, + voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index b29681d9b..c90254cc4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -65,7 +65,7 @@ protocol RoomTimelineControllerProtocol { func retryDecryption(for sessionID: String) async - func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? + func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState func playPauseAudio(for itemID: TimelineItemIdentifier) async diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessageRoomTimelineItem.swift similarity index 92% rename from ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceRoomTimelineItem.swift rename to ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessageRoomTimelineItem.swift index 29f08c7d7..780576d04 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessageRoomTimelineItem.swift @@ -16,7 +16,7 @@ import Foundation -struct VoiceRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable { +struct VoiceMessageRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable { let id: TimelineItemIdentifier let timestamp: String let isOutgoing: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift similarity index 57% rename from ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift rename to ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift index 9aaae35d1..ead54111c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift @@ -16,12 +16,17 @@ import SwiftUI -struct VoiceRoomPlaybackView: View { - @ObservedObject var playbackViewState: VoiceRoomPlaybackViewState +struct VoiceMessageRoomPlaybackView: View { + @ObservedObject var playerState: AudioPlayerState + let onPlayPause: () -> Void + let onSeek: (Double) -> Void + let onScrubbing: (Bool) -> Void private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) @State private var sendFeedback = false + @ScaledMetric private var waveformLineWidth = 2.0 + @ScaledMetric private var waveformLinePadding = 2.0 private let waveformMaxWidth: CGFloat = 150 private let playPauseButtonSize = CGSize(width: 32, height: 32) @@ -30,55 +35,18 @@ struct VoiceRoomPlaybackView: View { dateFormatter.dateFormat = "m:ss" return dateFormatter }() - - var onPlayPause: () -> Void = { } - var onSeek: (Double) -> Void = { _ in } - var onScrubbing: (Bool) -> Void = { _ in } - - private enum DragState: Equatable { - case inactive - case pressing(progress: Double) - case dragging(progress: Double) - var progress: Double { - switch self { - case .inactive, .pressing: - return .zero - case .dragging(let progress): - return progress - } - } - - var isActive: Bool { - switch self { - case .inactive: - return false - case .pressing, .dragging: - return true - } - } - - var isDragging: Bool { - switch self { - case .inactive, .pressing: - return false - case .dragging: - return true - } - } - } - @GestureState private var dragState = DragState.inactive @State private var tapProgress: Double = .zero var timeLabelContent: String { // Display the duration if progress is 0.0 - let percent = playbackViewState.progress > 0.0 ? playbackViewState.progress : 1.0 - return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playbackViewState.duration * percent)) + let percent = playerState.progress > 0.0 ? playerState.progress : 1.0 + return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playerState.duration * percent)) } var showWaveformCursor: Bool { - playbackViewState.playing || dragState.isDragging + playerState.playbackState == .playing || dragState.isDragging } var body: some View { @@ -90,28 +58,27 @@ struct VoiceRoomPlaybackView: View { .foregroundColor(.compound.textSecondary) .monospacedDigit() } - .padding(.vertical, 6) GeometryReader { geometry in - WaveformView(waveform: playbackViewState.waveform, progress: playbackViewState.progress, showCursor: showWaveformCursor) + WaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: playerState.waveform, progress: playerState.progress, showCursor: showWaveformCursor) // Add a gesture to drag the waveform .gesture(SpatialTapGesture() .simultaneously(with: LongPressGesture()) - .sequenced(before: DragGesture(coordinateSpace: .local)) + .sequenced(before: DragGesture(minimumDistance: waveformLinePadding, coordinateSpace: .local)) .updating($dragState) { value, state, _ in switch value { // (SpatialTap, LongPress) begins. - case .first(let spatialLongPress) where spatialLongPress.second == true: + case .first(let spatialLongPress) where spatialLongPress.second ?? false: // Compute the progress with the spatialTap location let progress = (spatialLongPress.first?.location ?? .zero).x / geometry.size.width state = .pressing(progress: progress) // Long press confirmed, dragging may begin. - case .second(let spatialLongPress, let drag) where spatialLongPress.second == true: + case .second(let spatialLongPress, let drag) where spatialLongPress.second ?? false: var progress: Double = tapProgress // Compute the progress with drag location if let loc = drag?.location { progress = loc.x / geometry.size.width } - state = .dragging(progress: progress) + state = .dragging(progress: progress, distance: geometry.size.width) // Dragging ended or the long press cancelled. default: state = .inactive @@ -129,18 +96,21 @@ struct VoiceRoomPlaybackView: View { onScrubbing(true) feedbackGenerator.prepare() sendFeedback = true - case .dragging(let progress): + case .dragging(let progress, let totalWidth): if sendFeedback { feedbackGenerator.impactOccurred() sendFeedback = false } - if abs(progress - playbackViewState.progress) > 0.01 { + let minimumProgress = waveformLinePadding / totalWidth + let deltaProgress = abs(progress - playerState.progress) + let deltaTime = playerState.duration * deltaProgress + if deltaProgress == 0 || deltaProgress >= minimumProgress || deltaTime >= 1.0 { onSeek(max(0, min(progress, 1.0))) } } } - .padding(.vertical, 2) - .padding(.horizontal, 8) + .padding(.leading, 2) + .padding(.trailing, 8) } @ViewBuilder @@ -148,33 +118,73 @@ struct VoiceRoomPlaybackView: View { Button { onPlayPause() } label: { - Image(systemName: playbackViewState.playing ? "pause.fill" : "play.fill") - .foregroundColor(.compound.iconSecondary) - .background( - Circle() - .frame(width: playPauseButtonSize.width, - height: playPauseButtonSize.height) - .foregroundColor(.compound.bgCanvasDefault) - ) - .padding(.trailing, 7) + ZStack { + Circle() + .foregroundColor(.compound.bgCanvasDefault) + if playerState.playbackState == .loading { + ProgressView() + } else { + Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay) + .offset(x: playerState.playbackState == .playing ? 0 : 2) + .aspectRatio(contentMode: .fit) + .foregroundColor(.compound.iconSecondary) + } + } + } + .disabled(playerState.playbackState == .loading) + .frame(width: playPauseButtonSize.width, + height: playPauseButtonSize.height) + } +} + +private enum DragState: Equatable { + case inactive + case pressing(progress: Double) + case dragging(progress: Double, distance: Double) + + var progress: Double { + switch self { + case .inactive, .pressing: + return .zero + case .dragging(let progress, _): + return progress + } + } + + var isActive: Bool { + switch self { + case .inactive: + return false + case .pressing, .dragging: + return true + } + } + + var isDragging: Bool { + switch self { + case .inactive, .pressing: + return false + case .dragging: + return true } } } -struct VoiceRoomPlaybackView_Previews: PreviewProvider, TestablePreview { +struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview { static let waveform = Waveform(data: [3, 127, 400, 266, 126, 122, 373, 251, 45, 112, 334, 205, 99, 138, 397, 354, 125, 361, 199, 51, 294, 131, 19, 2, 3, 3, 1, 2, 0, 0, 0, 0, 0, 0, 0, 3]) - static let playbackViewState = VoiceRoomPlaybackViewState(duration: 10.0, - waveform: waveform, - progress: 0.3) + static let playerState = AudioPlayerState(duration: 10.0, + waveform: waveform, + progress: 0.3) static var previews: some View { - VoiceRoomPlaybackView(playbackViewState: playbackViewState, - onPlayPause: { playbackViewState.updateState(playing: !playbackViewState.playing) }, - onSeek: { playbackViewState.updateState(progress: $0) }) + VoiceMessageRoomPlaybackView(playerState: playerState, + onPlayPause: { }, + onSeek: { value in Task { await playerState.updateState(progress: value) } }, + onScrubbing: { _ in }) .fixedSize(horizontal: false, vertical: true) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomTimelineView.swift new file mode 100644 index 000000000..863a1626e --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomTimelineView.swift @@ -0,0 +1,99 @@ +// +// Copyright 2023 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 + +struct VoiceMessageRoomTimelineView: View { + @EnvironmentObject private var context: RoomScreenViewModel.Context + private let timelineItem: VoiceMessageRoomTimelineItem + private let playerState: AudioPlayerState + @State private var resumePlaybackAfterScrubbing = false + + init(timelineItem: VoiceMessageRoomTimelineItem, playerState: AudioPlayerState) { + self.timelineItem = timelineItem + self.playerState = playerState + } + + var body: some View { + TimelineStyler(timelineItem: timelineItem) { + VoiceMessageRoomPlaybackView(playerState: playerState, + onPlayPause: onPlaybackPlayPause, + onSeek: { onPlaybackSeek($0) }, + onScrubbing: { onPlaybackScrubbing($0) }) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func onPlaybackPlayPause() { + context.send(viewAction: .playPauseAudio(itemID: timelineItem.id)) + } + + private func onPlaybackSeek(_ progress: Double) { + context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress)) + } + + private func onPlaybackScrubbing(_ dragging: Bool) { + if dragging { + if playerState.playbackState == .playing { + resumePlaybackAfterScrubbing = true + context.send(viewAction: .playPauseAudio(itemID: timelineItem.id)) + } + context.send(viewAction: .disableLongPress(itemID: timelineItem.id)) + } else { + context.send(viewAction: .enableLongPress(itemID: timelineItem.id)) + if resumePlaybackAfterScrubbing { + context.send(viewAction: .playPauseAudio(itemID: timelineItem.id)) + resumePlaybackAfterScrubbing = false + } + } + } +} + +struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview { + static let viewModel = RoomScreenViewModel.mock + + static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: .random, + timestamp: "Now", + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "Bob"), + content: .init(body: "audio.ogg", + duration: 300, + waveform: Waveform.mockWaveform, + source: nil, + contentType: nil)) + + static let playerState = AudioPlayerState(duration: 10.0, + waveform: Waveform.mockWaveform, + progress: 0.4) + + static var previews: some View { + body.environmentObject(viewModel.context) + .previewDisplayName("Bubble") + body + .environment(\.timelineStyle, .plain) + .environmentObject(viewModel.context) + .previewDisplayName("Plain") + } + + static var body: some View { + VoiceMessageRoomTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackViewState.swift deleted file mode 100644 index f24440eef..000000000 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackViewState.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright 2023 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 Combine -import Foundation - -@MainActor -class VoiceRoomPlaybackViewState: ObservableObject { - let duration: Double - let waveform: Waveform - @Published private(set) var loading: Bool - @Published private(set) var playing: Bool - @Published private(set) var progress: Double - - init(duration: Double = 0.0, waveform: Waveform? = nil, progress: Double = 0.0) { - self.duration = duration - self.waveform = waveform ?? Waveform(data: []) - self.progress = progress - loading = false - playing = false - } - - func updateState(progress: Double) { - self.progress = max(0.0, min(progress, 1.0)) - } - - func updateState(playing: Bool) { - self.playing = playing - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift deleted file mode 100644 index 161ca0dcd..000000000 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// Copyright 2023 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 - -struct VoiceRoomTimelineView: View { - @EnvironmentObject private var context: RoomScreenViewModel.Context - let timelineItem: VoiceRoomTimelineItem - let playbackViewState: VoiceRoomPlaybackViewState - - init(timelineItem: VoiceRoomTimelineItem, playbackViewState: VoiceRoomPlaybackViewState?) { - self.timelineItem = timelineItem - if playbackViewState == nil { - MXLog.error("[VoiceRoomTimelineView] Voice audio playback state is missing") - } - self.playbackViewState = playbackViewState ?? VoiceRoomPlaybackViewState() - } - - var body: some View { - TimelineStyler(timelineItem: timelineItem) { - VoiceRoomPlaybackView(playbackViewState: playbackViewState, - onPlayPause: onPlaybackPlayPause, - onSeek: onPlaybackSeek(_:), - onScrubbing: onPlaybackScrubbing(_:)) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func onPlaybackPlayPause() { - context.send(viewAction: .playPauseAudio(itemID: timelineItem.id)) - } - - private func onPlaybackSeek(_ progress: Double) { - context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress)) - } - - private func onPlaybackScrubbing(_ dragging: Bool) { - if dragging { - context.send(viewAction: .disableLongPress(itemID: timelineItem.id)) - } else { - context.send(viewAction: .enableLongPress(itemID: timelineItem.id)) - } - } -} - -struct VoiceRoomTimelineView_Previews: PreviewProvider, TestablePreview { - static let viewModel = RoomScreenViewModel.mock - - static let voiceRoomTimelineItem = VoiceRoomTimelineItem(id: .random, - timestamp: "Now", - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - isThreaded: false, - sender: .init(id: "Bob"), - content: .init(body: "audio.ogg", - duration: 300, - waveform: Waveform.mockWaveform, - source: nil, - contentType: nil)) - - static let playbackViewState = VoiceRoomPlaybackViewState(duration: 10.0, - waveform: Waveform.mockWaveform, - progress: 0.4) - - static var previews: some View { - body.environmentObject(viewModel.context) - .previewDisplayName("Bubble") - body - .environment(\.timelineStyle, .plain) - .environmentObject(viewModel.context) - .previewDisplayName("Plain") - } - - static var body: some View { - VoiceRoomTimelineView(timelineItem: voiceRoomTimelineItem, playbackViewState: playbackViewState) - .fixedSize(horizontal: false, vertical: true) - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift index 0ea4945e8..101abcdde 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift @@ -40,8 +40,8 @@ extension Waveform { } struct WaveformView: View { - private let lineWidth: CGFloat = 2 - private let linePadding: CGFloat = 2 + var lineWidth: CGFloat = 2 + var linePadding: CGFloat = 2 var waveform: Waveform private let minimumGraphAmplitude: CGFloat = 1 var progress: CGFloat = 0.0 diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index bec7d786a..5befc9bed 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -276,19 +276,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { _ messageContent: AudioMessageContent, _ isOutgoing: Bool, _ isThreaded: Bool) -> RoomTimelineItemProtocol { - VoiceRoomTimelineItem(id: eventItemProxy.id, - timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - canBeRepliedTo: eventItemProxy.canBeRepliedTo, - isThreaded: isThreaded, - sender: eventItemProxy.sender, - content: buildAudioTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), - properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), - reactions: aggregateReactions(eventItemProxy.reactions), - deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + VoiceMessageRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + canBeRepliedTo: eventItemProxy.canBeRepliedTo, + isThreaded: isThreaded, + sender: eventItemProxy.sender, + content: buildAudioTimelineItemContent(messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus, + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) } private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy, diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 1302a3d7d..e97490427 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -75,7 +75,7 @@ struct RoomTimelineItemView: View { case .poll(let item): PollRoomTimelineView(timelineItem: item) case .voice(let item): - VoiceRoomTimelineView(timelineItem: item, playbackViewState: context.viewState.audioPlaybackViewStateProvider?(item.id)) + VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(duration: 0)) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift index 568f4d52d..073ce9d4f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift @@ -69,7 +69,7 @@ enum RoomTimelineItemType: Equatable { case group(CollapsibleTimelineItem) case location(LocationRoomTimelineItem) case poll(PollRoomTimelineItem) - case voice(VoiceRoomTimelineItem) + case voice(VoiceMessageRoomTimelineItem) init(item: RoomTimelineItemProtocol) { switch item { @@ -113,7 +113,7 @@ enum RoomTimelineItemType: Equatable { self = .location(item) case let item as PollRoomTimelineItem: self = .poll(item) - case let item as VoiceRoomTimelineItem: + case let item as VoiceMessageRoomTimelineItem: self = .voice(item) default: fatalError("Unknown timeline item") diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift new file mode 100644 index 000000000..dad799583 --- /dev/null +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift @@ -0,0 +1,67 @@ +// +// Copyright 2023 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 + +class VoiceMessageCache { + var temporaryFilesFolderURL: URL { + FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message") + } + + func cacheURL(for mediaSource: MediaSourceProxy, replacingExtension newExtension: String? = nil) -> URL { + var newURL = temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent) + if let newExtension { + newURL = newURL.deletingPathExtension().appendingPathExtension(newExtension) + } + return newURL + } + + func fileURL(for mediaSource: MediaSourceProxy, withExtension fileExtension: String? = nil) -> URL? { + var url = temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent) + if let fileExtension { + url = url.deletingPathExtension().appendingPathExtension(fileExtension) + } + return FileManager.default.fileExists(atPath: url.path()) ? url : nil + } + + func cache(mediaSource: MediaSourceProxy, using fileURL: URL) throws -> URL { + setupTemporaryFilesFolder() + let url = cacheURL(for: mediaSource) + try? FileManager.default.removeItem(at: url) + try FileManager.default.copyItem(at: fileURL, to: url) + return url + } + + func clearCache() { + if FileManager.default.fileExists(atPath: temporaryFilesFolderURL.path) { + do { + try FileManager.default.removeItem(at: temporaryFilesFolderURL) + } catch { + MXLog.error("Failed clearing cached disk files", context: error) + } + } + } + + // MARK: - Private + + private func setupTemporaryFilesFolder() { + do { + try FileManager.default.createDirectoryIfNeeded(at: temporaryFilesFolderURL, withIntermediateDirectories: true) + } catch { + MXLog.error("Failed to setup audio cache manager.") + } + } +} diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift new file mode 100644 index 000000000..e9c80bba0 --- /dev/null +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift @@ -0,0 +1,67 @@ +// +// Copyright 2023 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 + +enum VoiceMessageMediaManagerError: Error { + case unsupportedMimeTye +} + +class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol { + private let mediaProvider: MediaProviderProtocol + private let cache: VoiceMessageCache + + private let supportedVoiceMessageMimeType = "audio/ogg" + + /// Preferred audio file extension after conversion + private let preferredAudioExtension = "m4a" + + init(mediaProvider: MediaProviderProtocol) { + self.mediaProvider = mediaProvider + cache = VoiceMessageCache() + } + + deinit { + cache.clearCache() + } + + func loadVoiceMessageFromSource(_ source: MediaSourceProxy, body: String?) async throws -> URL { + guard let mimeType = source.mimeType, mimeType == supportedVoiceMessageMimeType else { + throw VoiceMessageMediaManagerError.unsupportedMimeTye + } + + // Do we already have a converted version? + if let fileURL = cache.fileURL(for: source, withExtension: preferredAudioExtension) { + return fileURL + } + + // Otherwise, load the file from source + guard case .success(let fileHandle) = await mediaProvider.loadFileFromSource(source, body: body) else { + throw MediaProviderError.failedRetrievingFile + } + let fileURL = try cache.cache(mediaSource: source, using: fileHandle.url) + + // Convert from ogg + let audioConverter = AudioConverter() + let convertedFileURL = cache.cacheURL(for: source, replacingExtension: preferredAudioExtension) + try audioConverter.convertToMPEG4AAC(sourceURL: fileURL, destinationURL: convertedFileURL) + + // we don't need the original file anymore + try? FileManager.default.removeItem(at: fileURL) + + return convertedFileURL + } +} diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManagerProtocol.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManagerProtocol.swift new file mode 100644 index 000000000..f802f7571 --- /dev/null +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManagerProtocol.swift @@ -0,0 +1,21 @@ +// +// Copyright 2023 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 + +protocol VoiceMessageMediaManagerProtocol { + func loadVoiceMessageFromSource(_ source: MediaSourceProxy, body: String?) async throws -> URL +} diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index f85f6bb61..31324acf4 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -194,6 +194,7 @@ targets: - package: Emojibase - package: WysiwygComposer - package: Prefire + - package: SwiftOGG sources: - path: ../Sources diff --git a/UITests/SupportingFiles/target.yml b/UITests/SupportingFiles/target.yml index 608da8ae8..87258e17d 100644 --- a/UITests/SupportingFiles/target.yml +++ b/UITests/SupportingFiles/target.yml @@ -40,6 +40,7 @@ targets: - package: Sentry - package: SnapshotTesting - package: Collections + - package: SwiftOGG info: path: ../SupportingFiles/Info.plist diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png index a8e5bc370..f3c72c516 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e12529919d3829493278947168ddf881d9b7ae7929de5912a7a9c759c9be1ce -size 62187 +oid sha256:65e67cda37af645057b17dda50c358fdd668ada01ced5e35bdd6f98f45d62c6a +size 64555 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png index 3eabcdc5f..bc75b06c2 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5da10a2b12265a9e3e40ee408030ebc773c938d70a6cf14c2bf4249296bdf93e -size 72786 +oid sha256:ccdf089f3797b43730655a249072b89df1ac111f7156f44c28bf4cf26dde0882 +size 73820 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png index 16c32d348..73ef4b555 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ccfa620baa535643770bd4db7a3c329493ac6bf0351847c03eaadc4831bc7f68 -size 69227 +oid sha256:5db9f1f6ceaa65dfcd1b2720e62572a4cff2f57abbc6773b9b9023589a79eb09 +size 69605 diff --git a/project.yml b/project.yml index bf30411f3..84d560b1d 100644 --- a/project.yml +++ b/project.yml @@ -116,3 +116,6 @@ packages: WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift exactVersion: 2.14.1 + SwiftOGG: + url: https://github.com/vector-im/swift-ogg + branch: 0.0.1