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 5 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
137 changes: 129 additions & 8 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 @@ -82,6 +83,8 @@
private var sendButtonTagMessage = 99
private var sendButtonTagVoice = 98

private var isVoiceRecordingLocked = false

private var actionTypeTranscribeVoiceMessage = "transcribe-voice-message"

private var imagePicker: UIImagePickerController?
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 @@ -171,6 +175,22 @@
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) {
Expand Down Expand Up @@ -264,7 +284,8 @@
"unreadMessageButton": self.unreadMessageButton,
"textInputbar": self.textInputbar,
"scrollToBottomButton": self.scrollToBottomButton,
"autoCompletionView": self.autoCompletionView
"autoCompletionView": self.autoCompletionView,
"voiceRecordingLockButton": self.voiceRecordingLockButton
]

let metrics = [
Expand All @@ -281,7 +302,11 @@
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 @@ -1147,7 +1172,7 @@

func sendStartedTypingMessage(to sessionId: String) {
// Workaround: TypingPrivacy should be checked locally, not from the remote server, use serverCapabilities for now
// TODO: Remove workaround for federated typing indicators.

Check warning on line 1175 in NextcloudTalk/BaseChatViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Todo Violation: TODOs should be resolved (Remove workaround for federate...) (todo)
guard let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: self.room.accountId)
else { return }

Expand Down Expand Up @@ -1492,6 +1517,73 @@
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)
hostingController.view.frame = .init(x: 0, y: 0, width: self.textInputbar.frame.size.width, height: 1)
guard let expandedVoiceMessageRecordingView = hostingController.view else { return }

self.expandedUIHostingController = hostingController
self.view.addSubview(expandedVoiceMessageRecordingView)

expandedVoiceMessageRecordingView.translatesAutoresizingMaskIntoConstraints = false

let views = [
"expandedVoiceMessageRecordingView": expandedVoiceMessageRecordingView
]

self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-(>=0)-[expandedVoiceMessageRecordingView]-(>=0)-|", metrics: nil, views: views))
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"])
Expand Down Expand Up @@ -1542,6 +1634,7 @@
let session = AVAudioSession.sharedInstance()
try? session.setActive(true)
recorder.record()
print("Recording started")
}
}

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

Expand Down Expand Up @@ -1771,7 +1865,7 @@
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}

func handleLongPressInVoiceMessageRecordButton(gestureRecognizer: UILongPressGestureRecognizer) {

Check warning on line 1868 in NextcloudTalk/BaseChatViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Cyclomatic Complexity Violation: Function should have complexity 10 or less; currently complexity is 13 (cyclomatic_complexity)
if self.rightButton.tag != sendButtonTagVoice {
return
}
Expand All @@ -1788,14 +1882,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 {
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 +1903,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 +1915,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 @@ -1838,6 +1954,11 @@
}
}

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

// MARK: - UIScrollViewDelegate methods

public override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
Expand Down Expand Up @@ -2520,7 +2641,7 @@
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 }

Expand Down Expand Up @@ -3344,7 +3465,7 @@

public func fileControllerDidLoadFile(_ fileController: NCChatFileController, with fileStatus: NCChatFileStatus) {
if fileController.messageType == kMessageTypeVoiceMessage {
if fileController.actionType == actionTypeTranscribeVoiceMessage {

Check warning on line 3468 in NextcloudTalk/BaseChatViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Cyclomatic Complexity Violation: Function should have complexity 10 or less; currently complexity is 11 (cyclomatic_complexity)
self.transcribeVoiceMessage(with: fileStatus)
} else {
self.setupVoiceMessagePlayer(with: fileStatus)
Expand Down
92 changes: 92 additions & 0 deletions NextcloudTalk/ExpandedVoiceMessageRecordingView.swift
Original file line number Diff line number Diff line change
@@ -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)
//}
Loading