From 257267907bb1b07dd93c3351fa028b230b474a80 Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Fri, 30 Aug 2024 08:23:06 -0500 Subject: [PATCH] WIP Continous Voice recording - Added a voice recording lock button - Added a new SwiftUI View for expanded voice recording Signed-off-by: rapterjet2004 --- NextcloudTalk.xcodeproj/project.pbxproj | 4 + NextcloudTalk/BaseChatViewController.swift | 136 ++++++++++++++++-- .../ExpandedVoiceMessageRecordingView.swift | 92 ++++++++++++ 3 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 NextcloudTalk/ExpandedVoiceMessageRecordingView.swift diff --git a/NextcloudTalk.xcodeproj/project.pbxproj b/NextcloudTalk.xcodeproj/project.pbxproj index b3515c77b..63f34920b 100644 --- a/NextcloudTalk.xcodeproj/project.pbxproj +++ b/NextcloudTalk.xcodeproj/project.pbxproj @@ -590,6 +590,7 @@ 847EFC7236336B67A1A89358 /* libPods-BroadcastUploadExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A3D305FCD7BF7E727A62F35 /* libPods-BroadcastUploadExtension.a */; }; 8789AE73BFCAA413B43319C0 /* libPods-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 684807120F4439797973DF73 /* libPods-ShareExtension.a */; }; 9993261EDAC77481FF4EF58A /* libPods-NextcloudTalk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F7C31E9D74F550EAF89931B /* libPods-NextcloudTalk.a */; }; + C65D252D2C7581A200157A89 /* ExpandedVoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65D252C2C7581A200157A89 /* ExpandedVoiceMessageRecordingView.swift */; }; DA1AEFC3270F1FA90088E519 /* DateLabelCustom.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1AEFC2270F1FA90088E519 /* DateLabelCustom.swift */; }; DA66582B27B6992F00B46B11 /* UserProfileTableViewController+AvatarSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA66582A27B6992F00B46B11 /* UserProfileTableViewController+AvatarSetup.swift */; }; DA66582D27B6A73800B46B11 /* UserProfileTableViewController+DelegateMethods.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA66582C27B6A73800B46B11 /* UserProfileTableViewController+DelegateMethods.swift */; }; @@ -1189,6 +1190,7 @@ 9B81BB7A4920C391CC2CACFD /* libPods-NotificationServiceExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NotificationServiceExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; A8F95DE6635ABC1E64CA8E4A /* Pods-BroadcastUploadExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BroadcastUploadExtension.release.xcconfig"; path = "Pods/Target Support Files/Pods-BroadcastUploadExtension/Pods-BroadcastUploadExtension.release.xcconfig"; sourceTree = ""; }; B7874918820589BF8FD69BED /* Pods-NextcloudTalkTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NextcloudTalkTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-NextcloudTalkTests/Pods-NextcloudTalkTests.release.xcconfig"; sourceTree = ""; }; + C65D252C2C7581A200157A89 /* ExpandedVoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandedVoiceMessageRecordingView.swift; sourceTree = ""; }; D6DF51D976DC0F681FF83F7B /* Pods-NotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; D86091EC1125C3057B9A299B /* Pods-NotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.release.xcconfig"; path = "Pods/Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; DA1AEFC2270F1FA90088E519 /* DateLabelCustom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateLabelCustom.swift; sourceTree = ""; }; @@ -1925,6 +1927,7 @@ 1F66B72029FA7089003FB168 /* TypingIndicatorView.xib */, 2C0424992CA33681004772F6 /* AudioPlayerView.swift */, 2C0424962CA335C4004772F6 /* AudioPlayerView.xib */, + C65D252C2C7581A200157A89 /* ExpandedVoiceMessageRecordingView.swift */, ); name = "Chat views"; sourceTree = ""; @@ -2973,6 +2976,7 @@ 1F0B0A722BA264540073FF8D /* MentionSuggestion.swift in Sources */, 2CA1CCCD1F181741002FE6A2 /* NCUser.m in Sources */, 1F77A6162AB9B161007B6037 /* ScreenCaptureController.m in Sources */, + C65D252D2C7581A200157A89 /* ExpandedVoiceMessageRecordingView.swift in Sources */, 2CF8AD3F2A0010FB00A4D3E6 /* MessageTranslationViewController.swift in Sources */, 2C21446E2BB5B54D005A6537 /* BaseChatTableViewCell+Location.swift in Sources */, 2C4230F72B207AB00013E1FA /* ContextChatViewController.swift in Sources */, diff --git a/NextcloudTalk/BaseChatViewController.swift b/NextcloudTalk/BaseChatViewController.swift index eb824be74..5ab17aa9e 100644 --- a/NextcloudTalk/BaseChatViewController.swift +++ b/NextcloudTalk/BaseChatViewController.swift @@ -10,6 +10,7 @@ import UIKit import Realm import ContactsUI import QuickLook +import SwiftUI @objcMembers public class BaseChatViewController: InputbarViewController, UITextFieldDelegate, @@ -80,6 +81,8 @@ import QuickLook private var sendButtonTagMessage = 99 private var sendButtonTagVoice = 98 + private var isVoiceRecordingLocked = false + private var actionTypeTranscribeVoiceMessage = "transcribe-voice-message" private var imagePicker: UIImagePickerController? @@ -89,6 +92,7 @@ import QuickLook private var voiceMessageLongPressGesture: UILongPressGestureRecognizer? private var recorder: AVAudioRecorder? private var voiceMessageRecordingView: VoiceMessageRecordingView? + private var expandedUIHostingController: UIHostingController? private var longPressStartingPoint: CGPoint? private var cancelHintLabelInitialPositionX: CGFloat? private var recordCancelled: Bool = false @@ -169,6 +173,22 @@ import QuickLook return button }() + private lazy var voiceRecordingLockButton: UIButton = { + let button = UIButton(frame: .init(x: 0, y: 0, width: 44, height: 44)) + + button.backgroundColor = .secondarySystemBackground + button.tintColor = .systemBlue + button.layer.cornerRadius = button.frame.size.height / 2 + button.clipsToBounds = true + button.alpha = 0 + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: "lock.open"), for: .normal) + + self.view.addSubview(button) + + return button + }() + // MARK: - Init/Deinit public init?(for room: NCRoom) { @@ -264,7 +284,8 @@ import QuickLook "unreadMessageButton": self.unreadMessageButton, "textInputbar": self.textInputbar, "scrollToBottomButton": self.scrollToBottomButton, - "autoCompletionView": self.autoCompletionView + "autoCompletionView": self.autoCompletionView, + "voiceRecordingLockButton": self.voiceRecordingLockButton ] let metrics = [ @@ -281,7 +302,11 @@ import QuickLook self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:[scrollToBottomButton(44)]-10-[autoCompletionView]", metrics: metrics, views: views)) self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-(>=0)-[scrollToBottomButton(44)]-(>=0)-|", metrics: metrics, views: views)) + self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:[voiceRecordingLockButton(44)]-64-[autoCompletionView]", metrics: metrics, views: views)) + self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-(>=0)-[voiceRecordingLockButton(44)]-(>=0)-|", metrics: metrics, views: views)) + self.scrollToBottomButton.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, constant: -10).isActive = true + self.voiceRecordingLockButton.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, constant: -10).isActive = true self.addMenuToLeftButton() @@ -1492,6 +1517,72 @@ import QuickLook self.voiceMessageRecordingView?.isHidden = true } + // MARK: - Expanded voice message recording + + func showExpandedVoiceMessageRecordingView(offset: Int) { + let expandedView = ExpandedVoiceMessageRecordingView( + deleteFunc: handleDelete, sendFunc: handleSend, recordFunc: handleRecord(isRecording:), timeElapsed: offset + ) + + let hostingController = UIHostingController(rootView: expandedView) + guard let expandedVoiceMessageRecordingView = hostingController.view else { return } + + self.expandedUIHostingController = hostingController + self.view.addSubview(expandedVoiceMessageRecordingView) + + expandedVoiceMessageRecordingView.translatesAutoresizingMaskIntoConstraints = false + + let views = [ + "expandedVoiceMessageRecordingView": expandedVoiceMessageRecordingView + ] + + expandedVoiceMessageRecordingView.bottomAnchor.constraint(equalTo: self.textInputbar.bottomAnchor).isActive = true + self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[expandedVoiceMessageRecordingView]|", metrics: nil, views: views)) + } + + func handleDelete() { + self.recordCancelled = true + self.stopRecordingVoiceMessage() + handleCollapseVoiceRecording() + } + + func handleSend() { + if let recorder = self.recorder, recorder.isRecording { + self.recordCancelled = false + self.stopRecordingVoiceMessage() + } else { + self.hideVoiceMessageRecordingView() + self.shareVoiceMessage() + } + handleCollapseVoiceRecording() + } + + func handleRecord(isRecording: Bool) { + if isRecording { + if let recorder = self.recorder, !recorder.isRecording { + let session = AVAudioSession.sharedInstance() + try? session.setActive(true) + recorder.record() + print("Recording Restarted") + } + } else { + recordCancelled = true + if let recorder = self.recorder, recorder.isRecording { + recorder.stop() + let session = AVAudioSession.sharedInstance() + try? session.setActive(false) + print("Recording Stopped") + } + } + } + + func handleCollapseVoiceRecording() { + self.isVoiceRecordingLocked = false + self.expandedUIHostingController?.removeFromParent() + self.expandedUIHostingController?.view.isHidden = true + self.textInputbar.bringSubviewToFront(self.textInputbar) + } + func setupAudioRecorder() { guard let userDocumentDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).last, let outputFileURL = NSURL.fileURL(withPathComponents: [userDocumentDirectory, "voice-message-recording.m4a"]) @@ -1542,6 +1633,7 @@ import QuickLook let session = AVAudioSession.sharedInstance() try? session.setActive(true) recorder.record() + print("Recording started") } } @@ -1551,6 +1643,7 @@ import QuickLook recorder.stop() let session = AVAudioSession.sharedInstance() try? session.setActive(false) + print("Recording Stopped") } } @@ -1788,14 +1881,19 @@ import QuickLook self.recordCancelled = false self.longPressStartingPoint = point self.cancelHintLabelInitialPositionX = voiceMessageRecordingView?.slideToCancelHintLabel?.frame.origin.x + self.voiceRecordingLockButton.alpha = 1 } else if gestureRecognizer.state == .ended { - print("Stop recording audio message") self.shouldLockInterfaceOrientation(lock: false) - if let recordingTime = self.recorder?.currentTime { - // Mark record as cancelled if audio message is no longer than one second - self.recordCancelled = recordingTime < 1 + self.resetVoiceRecordingLockButton() + + if !isVoiceRecordingLocked { + if let recordingTime = self.recorder?.currentTime { + // Mark record as cancelled if audio message is no longer than one second + self.recordCancelled = recordingTime < 1 + } + self.stopRecordingVoiceMessage() + print("Stop recording audio message") } - self.stopRecordingVoiceMessage() } else if gestureRecognizer.state == .changed { guard let longPressStartingPoint, let cancelHintLabelInitialPositionX, @@ -1804,6 +1902,7 @@ import QuickLook else { return } let slideX = longPressStartingPoint.x - point.x + let slideY = longPressStartingPoint.y - point.y // Only slide view to the left if slideX > 0 { @@ -1815,19 +1914,35 @@ import QuickLook slideToCancelHintLabel.alpha = (maxSlideX - slideX) / 100 // Cancel recording if slided more than maxSlideX - if slideX > maxSlideX, !self.recordCancelled { + if slideX > maxSlideX, !self.recordCancelled, !isVoiceRecordingLocked { print("Cancel recording audio message") // 'Cancelled' feedback (three sequential weak booms) AudioServicesPlaySystemSound(1521) self.recordCancelled = true self.stopRecordingVoiceMessage() + self.resetVoiceRecordingLockButton() + } + } + + if slideY > 0 { + let maxSlideY = 64.0 + if slideY > maxSlideY, !self.recordCancelled { + if !isVoiceRecordingLocked { + self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock"), for: .normal) + let offset = self.voiceMessageRecordingView?.recordingTimeLabel?.getTimeCounted() + let intOffset = Int(offset!.magnitude) + showExpandedVoiceMessageRecordingView(offset: intOffset) + print("LOCKED") + isVoiceRecordingLocked = true + } } } } else if gestureRecognizer.state == .cancelled || gestureRecognizer.state == .failed { print("Gesture cancelled or failed -> Cancel recording audio message") self.shouldLockInterfaceOrientation(lock: false) self.recordCancelled = false + self.resetVoiceRecordingLockButton() self.stopRecordingVoiceMessage() } } @@ -1838,6 +1953,11 @@ import QuickLook } } + func resetVoiceRecordingLockButton() { + self.voiceRecordingLockButton.alpha = 0 + self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock.open"), for: .normal) + } + // MARK: - UIScrollViewDelegate methods public override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { @@ -2522,7 +2642,7 @@ import QuickLook guard let message = self.message(for: indexPath) else { continue } DispatchQueue.global(qos: .userInitiated).async { - guard message.messageId != kUnreadMessagesSeparatorIdentifier, + guard message.messageId != kUnreadMessagesSeparatorIdentifier, message.messageId != kChatBlockSeparatorIdentifier else { return } diff --git a/NextcloudTalk/ExpandedVoiceMessageRecordingView.swift b/NextcloudTalk/ExpandedVoiceMessageRecordingView.swift new file mode 100644 index 000000000..ea9ba27ad --- /dev/null +++ b/NextcloudTalk/ExpandedVoiceMessageRecordingView.swift @@ -0,0 +1,92 @@ +// +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI + +func formatSeconds(seconds: Int) -> String { + let minutes = seconds / 60 + let seconds = seconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + +} + +struct ExpandedVoiceMessageRecordingView: View { + var buttonPadding: CGFloat = 40 + var deleteFunc: () -> Void + var sendFunc: () -> Void + var recordFunc: (Bool) -> Void + + @State var isRecording = true + @State var timeElapsed: Int + @State var timeFormatted = "" + @State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + VStack { + Text("\(timeFormatted)") + .font(.largeTitle) + .bold() + .padding(.trailing, 10) + .frame(alignment: .center) + .border(.clear) + .onReceive(timer) { _ in + if isRecording { + timeElapsed += 1 + timeFormatted = formatSeconds(seconds: timeElapsed) + } + } + HStack { + Button(action: { // Delete Recording + self.deleteFunc() + }, label: { + Label("", systemImage: "trash").font(.title2) + }) + Spacer() + Button(action: { // End/Restart Recording + isRecording.toggle() + + if isRecording { + timeElapsed = 0 + timeFormatted = formatSeconds(seconds: 0) + timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + } + + self.recordFunc(isRecording) + + }, label: { + Label("", systemImage: isRecording ? "square.fill" : "arrow.clockwise.square").font(.title2) + }) + Spacer() + Button(action: { // Send Recording + self.sendFunc() + + }, label: { + Label("", systemImage: "paperplane").font(.title2) + }) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, buttonPadding) + .padding(.bottom, 10) + .border(.clear) + + } + .frame(maxWidth: .infinity) + .border(.clear) + .background(Color(NCAppBranding.backgroundColor())) + .onAppear { + timeFormatted = formatSeconds(seconds: timeElapsed) + } + } +} + +//#Preview { +// ExpandedVoiceMessageRecordingView(deleteFunc: { +// // unused atm +// }, sendFunc: { +// // unused atm +// }, recordFunc: { _ in +// // unused atm +// }, timeElapsed: 0) +//}