Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Continuous Voice recording #1786

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NextcloudTalk.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,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 */; };
Expand Down Expand Up @@ -1154,6 +1155,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 = "<group>"; };
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 = "<group>"; };
C65D252C2C7581A200157A89 /* ExpandedVoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandedVoiceMessageRecordingView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
DA1AEFC2270F1FA90088E519 /* DateLabelCustom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateLabelCustom.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1880,6 +1882,7 @@
2CA52ACC2670D07900619610 /* VoiceMessageRecordingView.xib */,
1F66B71E29FA703B003FB168 /* TypingIndicatorView.swift */,
1F66B72029FA7089003FB168 /* TypingIndicatorView.xib */,
C65D252C2C7581A200157A89 /* ExpandedVoiceMessageRecordingView.swift */,
);
name = "Chat views";
sourceTree = "<group>";
Expand Down Expand Up @@ -2914,6 +2917,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 */,
Expand Down
141 changes: 134 additions & 7 deletions NextcloudTalk/BaseChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import Realm
import ContactsUI
import QuickLook
import SwiftUI

@objcMembers public class BaseChatViewController: InputbarViewController,
UITextFieldDelegate,
Expand Down Expand Up @@ -81,6 +82,8 @@
// MARK: - Private var
private var sendButtonTagMessage = 99
private var sendButtonTagVoice = 98

private var isVoiceRecordingLocked = false

private var actionTypeTranscribeVoiceMessage = "transcribe-voice-message"

Expand All @@ -91,6 +94,7 @@
private var voiceMessageLongPressGesture: UILongPressGestureRecognizer?
private var recorder: AVAudioRecorder?
private var voiceMessageRecordingView: VoiceMessageRecordingView?
private var expandedUIHostingController: UIHostingController<ExpandedVoiceMessageRecordingView>?
private var longPressStartingPoint: CGPoint?
private var cancelHintLabelInitialPositionX: CGFloat?
private var recordCancelled: Bool = false
Expand Down Expand Up @@ -170,6 +174,24 @@

return button
}()

private lazy var voiceRecordingLockButton: UIButton = {
let button = UIButton(frame: .init(x: 0, y: 0, width: 44, height: 44), primaryAction: UIAction { [weak self] _ in
// Unused atm
})
rapterjet2004 marked this conversation as resolved.
Show resolved Hide resolved

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

Expand Down Expand Up @@ -264,7 +286,8 @@
"unreadMessageButton": self.unreadMessageButton,
"textInputbar": self.textInputbar,
"scrollToBottomButton": self.scrollToBottomButton,
"autoCompletionView": self.autoCompletionView
"autoCompletionView": self.autoCompletionView,
"voiceRecordingLockButton": self.voiceRecordingLockButton
]

let metrics = [
Expand All @@ -280,8 +303,12 @@

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

Expand Down Expand Up @@ -1491,6 +1518,77 @@
func hideVoiceMessageRecordingView() {
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
)

self.expandedUIHostingController = UIHostingController(rootView: expandedView)
self.textInputbar.addSubview(self.expandedUIHostingController!.view)

guard let expandedVoiceMessageRecordingView = self.expandedUIHostingController!.view else { return }
expandedVoiceMessageRecordingView.translatesAutoresizingMaskIntoConstraints = false


let views = [
"expandedVoiceMessageRecordingView": expandedVoiceMessageRecordingView
]

self.textInputbar.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[expandedVoiceMessageRecordingView]|", metrics: nil, views: views))
self.textInputbar.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[expandedVoiceMessageRecordingView]|", metrics: nil, views: views))

}
rapterjet2004 marked this conversation as resolved.
Show resolved Hide resolved

func handleDelete() {
print("Delete Handled")
rapterjet2004 marked this conversation as resolved.
Show resolved Hide resolved
self.recordCancelled = true
self.stopRecordingVoiceMessage()
handleCollapseVoiceRecording()
}

func handleSend() {
print("Send Handled")
if let recorder = self.recorder, recorder.isRecording {
self.recordCancelled = false
self.stopRecordingVoiceMessage()
} else {
self.hideVoiceMessageRecordingView()
self.shareVoiceMessage()
}
handleCollapseVoiceRecording()
}

func handleRecord(isRecording: Bool) {
print("Record Handled with", isRecording)
if (!isRecording) {

Check warning on line 1567 in NextcloudTalk/BaseChatViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Control Statement Violation: `if`, `for`, `guard`, `switch`, `while`, and `catch` statements shouldn't unnecessarily wrap their conditionals or arguments in parentheses (control_statement)
recordCancelled = true
if let recorder = self.recorder, recorder.isRecording {
recorder.stop()
let session = AVAudioSession.sharedInstance()
try? session.setActive(false)
print("Recording Stopped")
}
} else {
if let recorder = self.recorder, !recorder.isRecording {
let session = AVAudioSession.sharedInstance()
try? session.setActive(true)
recorder.record()
print("Recording Restarted")
}

}
}

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,
Expand Down Expand Up @@ -1542,6 +1640,7 @@
let session = AVAudioSession.sharedInstance()
try? session.setActive(true)
recorder.record()
print("Recording started")
}
}

Expand All @@ -1551,6 +1650,7 @@
recorder.stop()
let session = AVAudioSession.sharedInstance()
try? session.setActive(false)
print("Recording Stopped")
}
}

Expand Down Expand Up @@ -1788,14 +1888,19 @@
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) {

Check warning on line 1896 in NextcloudTalk/BaseChatViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Control Statement Violation: `if`, `for`, `guard`, `switch`, `while`, and `catch` statements shouldn't unnecessarily wrap their conditionals or arguments in parentheses (control_statement)
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,
Expand All @@ -1804,6 +1909,7 @@
else { return }

let slideX = longPressStartingPoint.x - point.x
let slideY = longPressStartingPoint.y - point.y

// Only slide view to the left
if slideX > 0 {
Expand All @@ -1815,19 +1921,35 @@
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()
}
}
Expand All @@ -1837,6 +1959,11 @@
appDelegate.shouldLockInterfaceOrientation = lock
}
}

func resetVoiceRecordingLockButton() {
self.voiceRecordingLockButton.alpha = 0
self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock.open"), for: .normal)
}

// MARK: - UIScrollViewDelegate methods

Expand Down
93 changes: 93 additions & 0 deletions NextcloudTalk/ExpandedVoiceMessageRecordingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// 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: () -> ()
var sendFunc: () -> ()
var recordFunc: (Bool) -> ()

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