diff --git a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift new file mode 100644 index 000000000..7e6f3e5ca --- /dev/null +++ b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift @@ -0,0 +1,320 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import Toast +import UIKit + +/// What the record button next to an empty input field records +enum RecordButtonMode: String { + case voice + case video +} + +/// Video messages: the record button records a short video instead of a voice message when it is in +/// video mode. The gestures are the ones of voice messages, see `handleLongPressInVoiceMessageRecordButton`. +extension BaseChatViewController { + + // MARK: - Availability + + var isVoiceMessageRecordingAvailable: Bool { + return NCDatabaseManager.sharedInstance().roomHasTalkCapability(.voiceMessage, for: self.room) && !self.room.isFederated + } + + /// Videos are sent as files, which is not possible in federated conversations (just like the attachment menu), + /// and the camera is in use during a call. + var isVideoMessageRecordingAvailable: Bool { + return !self.room.isFederated + && UIImagePickerController.isSourceTypeAvailable(.camera) + && NCRoomsManager.shared.callViewController == nil + } + + /// The mode that is used for recording, as the chosen mode might not be available in this conversation + var effectiveRecordMode: RecordButtonMode { + switch (self.isVoiceMessageRecordingAvailable, self.isVideoMessageRecordingAvailable) { + case (true, false): + return .voice + case (false, true): + return .video + default: + return self.recordButtonMode + } + } + + /// With scheduled messages the button is a clock that opens them, so it can not switch the mode + private var canSwitchRecordButtonMode: Bool { + return self.isVoiceMessageRecordingAvailable && self.isVideoMessageRecordingAvailable && !self.room.hasScheduledMessages + } + + // MARK: - Mode switch + + func handleTapOnRecordButton() { + if self.canSwitchRecordButtonMode { + self.recordButtonMode = self.effectiveRecordMode == .voice ? .video : .voice + self.showVoiceMessageRecordButton() + } + + self.showRecordButtonHint() + } + + private func showRecordButtonHint() { + let hint: String + + if self.canSwitchRecordButtonMode { + if self.effectiveRecordMode == .video { + hint = NSLocalizedString("Tap and hold to record a video. Tap to switch to voice messages.", comment: "") + } else { + hint = NSLocalizedString("Tap and hold to record a voice message. Tap to switch to video messages.", comment: "") + } + } else if self.effectiveRecordMode == .video { + hint = NSLocalizedString("Tap and hold to record a video, release the button to send it.", comment: "") + } else { + self.showVoiceMessageRecordHint() + return + } + + let toastPosition = CGPoint(x: self.textInputbar.center.x, y: self.textInputbar.center.y - self.textInputbar.frame.size.height) + self.view.makeToast(hint, duration: 3, point: toastPosition, title: nil, image: nil, completion: nil) + } + + // MARK: - Gesture + + /// Starts recording in the current mode when the record button is pressed and held + func startRecordingForGesture() { + // The mode is fixed for the whole gesture + self.isVideoGestureActive = self.effectiveRecordMode == .video + + if self.isVideoGestureActive { + self.checkPermissionsAndRecordVideoMessage() + } else { + self.checkPermissionAndRecordVoiceMessage() + } + + self.setInputbarImage(UIImage(systemName: self.isVideoGestureActive ? "video" : "mic"), for: self.rightButton) + } + + /// Stops what `startRecordingForGesture` started. A voice message is handled by its recorder, which + /// sends it when it is finished, so `send` only applies to videos. + func stopRecordingForGesture(send: Bool) { + if self.isVideoGestureActive { + self.finishVideoMessageRecording(send: send) + } else { + self.stopRecordingVoiceMessage() + } + } + + // MARK: - Permissions + + func checkPermissionsAndRecordVideoMessage() { + let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video) + let microphoneStatus = AVCaptureDevice.authorizationStatus(for: .audio) + + if cameraStatus == .authorized, microphoneStatus == .authorized { + self.startRecordingVideoMessage() + return + } + + if cameraStatus == .denied || cameraStatus == .restricted { + self.presentCaptureAccessDeniedAlert(title: NSLocalizedString("Could not access camera", comment: ""), + message: NSLocalizedString("Camera access is not allowed. Check your settings.", comment: "")) + return + } + + if microphoneStatus == .denied || microphoneStatus == .restricted { + self.presentCaptureAccessDeniedAlert(title: NSLocalizedString("Could not access microphone", comment: ""), + message: NSLocalizedString("Microphone access is not allowed. Check your settings.", comment: "")) + return + } + + // Like for voice messages, the recording does not start while the system asks for the permission + BaseChatViewController.requestCaptureAccessIfNeeded(for: .video) { + BaseChatViewController.requestCaptureAccessIfNeeded(for: .audio) {} + } + } + + fileprivate static func requestCaptureAccessIfNeeded(for mediaType: AVMediaType, completion: @escaping () -> Void) { + guard AVCaptureDevice.authorizationStatus(for: mediaType) == .notDetermined else { + completion() + return + } + + AVCaptureDevice.requestAccess(for: mediaType) { granted in + NSLog("Capture permission for %@ granted: %@", mediaType.rawValue, granted ? "YES" : "NO") + completion() + } + } + + private func presentCaptureAccessDeniedAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default)) + NCUserInterfaceController.sharedInstance().presentAlertViewController(alert) + } + + // MARK: - Recording + + private func startRecordingVideoMessage() { + // A recording that is still being stopped has its own recorder, so this only guards against a second start + guard self.videoMessageRecorder == nil else { return } + + // Playing a voice message and recording do not go together + self.pauseVoiceMessagePlayer() + + let recorder = VideoMessageRecorder(interfaceOrientation: CameraCaptureHelpers.interfaceOrientation(of: self.view)) + recorder.onFailure = { [weak self, weak recorder] in + guard let self, let recorder, self.videoMessageRecorder === recorder else { return } + + NCLog.log("Video message recording failed or was interrupted") + self.finishVideoMessageRecording(send: false) + self.presentVideoMessageDiscardedHint() + } + + self.videoMessageRecorder = recorder + + self.showVoiceMessageRecordingView(iconName: "video.fill") + self.showVideoMessagePreview(for: recorder) + + recorder.start { [weak self, weak recorder] success in + guard let self, let recorder, self.videoMessageRecorder === recorder else { return } + + guard success else { + NCLog.log("Could not start recording a video message") + self.finishVideoMessageRecording(send: false) + self.presentVideoMessageDiscardedHint() + return + } + + // The recording stops and is sent when the maximum duration is reached + let timer = Timer(timeInterval: VideoMessageRecorder.maxDuration, repeats: false) { [weak self] _ in + self?.finishVideoMessageRecording(send: true) + } + + RunLoop.main.add(timer, forMode: .common) + self.videoMessageLimitTimer = timer + } + } + + /// Stops recording, and sends the video unless it is cancelled or shorter than a second + func finishVideoMessageRecording(send: Bool) { + guard let recorder = self.videoMessageRecorder else { return } + + self.videoMessageRecorder = nil + self.videoMessageLimitTimer?.invalidate() + self.videoMessageLimitTimer = nil + + let isLongEnough = recorder.elapsed >= 1 + + self.hideVideoMessagePreview() + + self.hideVoiceMessageRecordingView() + self.handleCollapseVoiceRecording() + self.resetVoiceRecordingLockButton() + self.shouldLockInterfaceOrientation(lock: false) + + // Brings back the icon of the mode, or the clock when there are scheduled messages + self.showVoiceMessageRecordButton() + + recorder.stop(keepFile: send && isLongEnough) { [weak self] fileURL in + guard let fileURL else { return } + + self?.shareVideoMessage(fileURL: fileURL) + } + } + + /// A recording that could not go on is dropped, which would otherwise be invisible + private func presentVideoMessageDiscardedHint() { + let toastPosition = CGPoint(x: self.textInputbar.center.x, y: self.textInputbar.center.y - self.textInputbar.frame.size.height) + self.view.makeToast(NSLocalizedString("Video message could not be recorded", comment: ""), duration: 3, point: toastPosition, title: nil, image: nil, completion: nil) + } + + private func showVideoMessagePreview(for recorder: VideoMessageRecorder) { + // Dims the chat and keeps the touches away from it, the preview and the recording panel are the only things + // to use while recording + let scrimView = UIView() + scrimView.backgroundColor = UIColor.black.withAlphaComponent(0.5) + scrimView.isUserInteractionEnabled = true + scrimView.isAccessibilityElement = false + + let previewView = VideoMessagePreviewView(session: recorder.session, + interfaceOrientation: CameraCaptureHelpers.interfaceOrientation(of: self.view), + showsSwitchCameraButton: recorder.canSwitchCamera) + previewView.onSwitchCamera = { [weak recorder] in + recorder?.switchCamera() + } + + // The connection of the preview is created again with the camera, so the orientation is applied once more + recorder.onCameraSwitched = { [weak self] in + self?.videoMessagePreviewView?.setNeedsLayout() + self?.updateVideoMessagePreviewLayout() + } + + self.view.addSubview(scrimView) + self.view.addSubview(previewView) + self.videoMessageScrimView = scrimView + self.videoMessagePreviewView = previewView + + // The gesture of the lock needs its button to be seen + self.bringVoiceRecordingLockButtonToFront() + + // VoiceOver reads the recording panel and the switch camera button, not the chat behind them + self.tableView?.accessibilityElementsHidden = true + + self.updateVideoMessagePreviewLayout() + + UIAccessibility.post(notification: .layoutChanged, argument: previewView) + } + + /// Adds a view to the chat, below the dimming of a video recording when it is shown, so it is not left over it + func addBelowVideoMessageScrim(_ overlayView: UIView) { + if let scrimView = self.videoMessageScrimView { + self.view.insertSubview(overlayView, belowSubview: scrimView) + } else { + self.view.addSubview(overlayView) + } + } + + /// Removes the preview and what is shown with it, whatever the way the recording ended + private func hideVideoMessagePreview() { + self.videoMessagePreviewView?.removeFromSuperview() + self.videoMessagePreviewView = nil + self.videoMessageScrimView?.removeFromSuperview() + self.videoMessageScrimView = nil + self.lockedVideoSendButton?.removeFromSuperview() + self.lockedVideoSendButton = nil + + self.tableView?.accessibilityElementsHidden = false + self.rightButton.accessibilityElementsHidden = false + + UIAccessibility.post(notification: .layoutChanged, argument: nil) + } + + /// Places the preview in the free space above the recording panel: it follows the orientation of the interface, + /// and is placed again whenever the layout changes, e.g. by a rotation. + func updateVideoMessagePreviewLayout() { + guard let previewView = self.videoMessagePreviewView else { return } + + let inputbarTop = self.view.convert(self.textInputbar.bounds, from: self.textInputbar).minY + self.videoMessageScrimView?.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: max(inputbarTop, 0)) + + let insets = self.view.safeAreaInsets + let area = CGRect(x: insets.left, + y: insets.top, + width: self.view.bounds.width - insets.left - insets.right, + height: inputbarTop - insets.top) + + let orientation = CameraCaptureHelpers.interfaceOrientation(of: self.view) + + // Only what is shown follows the orientation. The file keeps the one the recording started in. + previewView.interfaceOrientation = orientation + previewView.frame = VideoMessagePreviewPlacement.frame(in: area, aspect: VideoMessagePreviewPlacement.frameAspect(for: orientation)) + } + + // MARK: - Sending + + /// Uploads the video like a voice message, without a confirmation, but as an ordinary file: it has no + /// message type, so everyone sees a regular video. + private func shareVideoMessage(fileURL: URL) { + self.shareRecording(fromPath: fileURL.path, namePrefix: "Talk video from", fileExtension: "mp4", isVoiceMessage: false) + } +} diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 6fc3dd74e..68c3cd79b 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -100,6 +100,18 @@ import Toast private var longPressStartingPoint: CGPoint? private var recordCancelled: Bool = false + // Video messages, see BaseChatViewController+VideoMessage.swift. The mode lives here and not in the + // button, as SlackTextViewController replaces the button state whenever the text changes. + internal var recordButtonMode = RecordButtonMode(rawValue: NCUserDefaults.preferredRecordButtonMode() ?? "") ?? .voice { + didSet { NCUserDefaults.setPreferredRecordButtonMode(recordButtonMode.rawValue) } + } + internal var isVideoGestureActive = false + internal var videoMessageRecorder: VideoMessageRecorder? + internal var videoMessagePreviewView: VideoMessagePreviewView? + internal var videoMessageScrimView: UIView? + internal var lockedVideoSendButton: UIButton? + internal var videoMessageLimitTimer: Timer? + private var animationDispatchGroup = DispatchGroup() private var animationDispatchQueue = DispatchQueue(label: "\(groupIdentifier).animationQueue") @@ -458,6 +470,9 @@ import Toast self.isVisible = false + // The camera is not available anymore without the view + self.finishVideoMessageRecording(send: false) + if !self.textInputbar.isHidden { self.savePendingMessage() } @@ -467,6 +482,22 @@ import Toast } } + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + self.updateVideoMessagePreviewLayout() + } + + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + // The orientation of the interface is the new one at the latest when the transition is done (on an iPad + // it can change after the layout), so the preview is placed once more then + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.updateVideoMessagePreviewLayout() + } + } + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -737,15 +768,23 @@ import Toast func showVoiceMessageRecordButton() { self.rightButton.setTitle("", for: .normal) + let isVideoMode = self.effectiveRecordMode == .video + if self.room.hasScheduledMessages { self.setInputbarImage(UIImage(systemName: "clock"), for: self.rightButton) } else { - self.setInputbarImage(UIImage(systemName: "mic"), for: self.rightButton) + self.setInputbarImage(UIImage(systemName: isVideoMode ? "video" : "mic"), for: self.rightButton) } self.rightButton.tag = sendButtonTagVoice - self.rightButton.accessibilityLabel = NSLocalizedString("Record voice message", comment: "") - self.rightButton.accessibilityHint = NSLocalizedString("Tap and hold to record a voice message", comment: "") + + if isVideoMode { + self.rightButton.accessibilityLabel = NSLocalizedString("Record video message", comment: "") + self.rightButton.accessibilityHint = NSLocalizedString("Tap and hold to record a video message", comment: "") + } else { + self.rightButton.accessibilityLabel = NSLocalizedString("Record voice message", comment: "") + self.rightButton.accessibilityHint = NSLocalizedString("Tap and hold to record a voice message", comment: "") + } self.addGestureRecognizerToRightButton() } @@ -856,7 +895,7 @@ import Toast let scheduledViewController = ScheduledMessagesChatViewController(forRoom: self.room, withAccount: self.account)! self.presentWithNavigation(scheduledViewController, animated: true) } else { - self.showVoiceMessageRecordHint() + self.handleTapOnRecordButton() } default: break @@ -867,6 +906,11 @@ import Toast // Remove a potential menu so it does not interfere with the long gesture recognizer self.rightButton.menu = nil + // Changing the mode of the record button does not replace the button, so avoid adding a second recognizer + if let voiceMessageLongPressGesture, self.rightButton.gestureRecognizers?.contains(voiceMessageLongPressGesture) == true { + return + } + // Add long press gesture recognizer for voice message recording button self.voiceMessageLongPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressInVoiceMessageRecordButton(gestureRecognizer:))) @@ -1975,11 +2019,13 @@ import Toast self.view.makeToast(NSLocalizedString("Tap and hold to record a voice message, release the button to send it.", comment: ""), duration: 3, point: toastPosition, title: nil, image: nil, completion: nil) } - func showVoiceMessageRecordingView() { + func showVoiceMessageRecordingView(iconName: String = "mic.fill") { self.voiceMessageRecordingView = VoiceMessageRecordingView() guard let voiceMessageRecordingView = self.voiceMessageRecordingView else { return } + voiceMessageRecordingView.recordingImageView.image = UIImage(systemName: iconName) + voiceMessageRecordingView.translatesAutoresizingMaskIntoConstraints = false self.textInputbar.addSubview(voiceMessageRecordingView) @@ -2052,6 +2098,11 @@ import Toast } func handleDelete() { + if self.videoMessageRecorder != nil { + self.finishVideoMessageRecording(send: false) + return + } + self.recordCancelled = true self.stopRecordingVoiceMessage() handleCollapseVoiceRecording() @@ -2059,6 +2110,11 @@ import Toast } func handleSend() { + if self.videoMessageRecorder != nil { + self.finishVideoMessageRecording(send: true) + return + } + if let recorder = self.recorder, recorder.isRecording { self.recordCancelled = false self.stopRecordingVoiceMessage() @@ -2095,6 +2151,45 @@ import Toast self.textInputbar.bringSubviewToFront(self.textInputbar) } + /// A locked video recording stays in the row of the inputbar, which shows the indicator and the time. It gets the + /// button to cancel in the row and the one to send over the record button, which is no longer held. + internal func showLockedVideoMessageActions() { + self.lockedVideoSendButton?.removeFromSuperview() + + self.voiceMessageRecordingView?.showCancelButton { [weak self] in + self?.handleDelete() + } + + var configuration = UIButton.Configuration.filled() + configuration.image = UIImage(systemName: "paperplane.fill") + configuration.cornerStyle = .capsule + configuration.baseBackgroundColor = NCAppBranding.themeColor() + configuration.baseForegroundColor = NCAppBranding.themeTextColor() + + let sendButton = UIButton(configuration: configuration) + sendButton.translatesAutoresizingMaskIntoConstraints = false + sendButton.accessibilityLabel = NSLocalizedString("Send message", comment: "") + sendButton.addAction(UIAction { [weak self] _ in self?.handleSend() }, for: .touchUpInside) + + self.textInputbar.addSubview(sendButton) + self.lockedVideoSendButton = sendButton + + // The send button takes its place, VoiceOver does not read the record button below it as well + self.rightButton.accessibilityElementsHidden = true + + NSLayoutConstraint.activate([ + sendButton.centerXAnchor.constraint(equalTo: self.rightButton.centerXAnchor), + sendButton.centerYAnchor.constraint(equalTo: self.rightButton.centerYAnchor), + sendButton.widthAnchor.constraint(equalTo: self.rightButton.widthAnchor), + sendButton.heightAnchor.constraint(equalTo: self.rightButton.heightAnchor) + ]) + } + + /// Brings the lock button of the recording above the views that are added for a video, which are shown over the chat + internal func bringVoiceRecordingLockButtonToFront() { + self.view.bringSubviewToFront(self.voiceRecordingLockButton) + } + func setupAudioRecorder() { guard let userDocumentDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).last, let outputFileURL = NSURL.fileURL(withPathComponents: [userDocumentDirectory, "voice-message-recording.m4a"]) @@ -2163,6 +2258,12 @@ import Toast self.showVoiceMessageRecordButton() guard let recorder = self.recorder else { return } + self.shareRecording(fromPath: recorder.url.path, namePrefix: "Talk recording from", fileExtension: "mp3", isVoiceMessage: true) + } + + /// Uploads a recording without a confirmation. Voice messages get a temporary message and the voice message + /// type, other recordings (videos) are ordinary files without both. + internal func shareRecording(fromPath sourcePath: String, namePrefix: String, fileExtension: String, isVoiceMessage: Bool) { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH-mm-ss" let dateString = dateFormatter.string(from: Date()) @@ -2176,18 +2277,26 @@ import Toast roomString = regex.stringByReplacingMatches(in: roomString, range: .init(location: 0, length: roomString.count), withTemplate: " ") } - var audioFileName = "Talk recording from \(dateString) (\(roomString))" + var baseFileName = "\(namePrefix) \(dateString) (\(roomString))" // Trim the file name if too long - if audioFileName.count > 146 { - audioFileName = String(audioFileName.prefix(146)) + if baseFileName.count > 146 { + baseFileName = String(baseFileName.prefix(146)) } - audioFileName += ".mp3" - let chatFileController = NCChatFileController(account: self.account) let tempDirectoryURL = URL(fileURLWithPath: chatFileController.tempDirectoryPath) - let destinationFilePath = tempDirectoryURL.appendingPathComponent(audioFileName).path + + // Never replace or lose a recording made in the same second, as the temporary directory is shared + var fileName = "\(baseFileName).\(fileExtension)" + var duplicateCounter = 1 + + while FileManager.default.fileExists(atPath: tempDirectoryURL.appendingPathComponent(fileName).path) { + duplicateCounter += 1 + fileName = "\(baseFileName) (\(duplicateCounter)).\(fileExtension)" + } + + let destinationFilePath = tempDirectoryURL.appendingPathComponent(fileName).path var replyToMessage: NCChatMessage? if let replyMessageView, replyMessageView.isVisible { @@ -2195,48 +2304,55 @@ import Toast replyMessageView.dismiss() } - if let temporaryMessage = self.createTemporaryMessage( - message: audioFileName, - replyTo: replyToMessage, - messageParameters: "\(destinationFilePath)", - silently: false, - isVoiceMessage: true - ) { - let movedFileToTemporaryDirectory = chatFileController.moveFileToTemporaryDirectory( - fromSourcePath: recorder.url.path, - destinationPath: destinationFilePath + var temporaryMessage: NCChatMessage? + + if isVoiceMessage { + temporaryMessage = self.createTemporaryMessage( + message: fileName, + replyTo: replyToMessage, + messageParameters: "\(destinationFilePath)", + silently: false, + isVoiceMessage: true ) - if !movedFileToTemporaryDirectory { - print("Failed to move voice-message to temporary directory.") + if temporaryMessage == nil { + print("Temporary message could not be created") return } + } - if movedFileToTemporaryDirectory, NCDatabaseManager.sharedInstance().roomHasTalkCapability(.chatReferenceId, for: room) { - self.appendTemporaryMessage(temporaryMessage: temporaryMessage) - } + let movedFileToTemporaryDirectory = chatFileController.moveFileToTemporaryDirectory( + fromSourcePath: sourcePath, + destinationPath: destinationFilePath + ) - var metaData = ChatFileUploadMetadata() - metaData.isVoiceMessage = true - metaData.replyTo = replyToMessage?.messageId - metaData.threadId = self.thread?.threadId + if !movedFileToTemporaryDirectory { + print("Failed to move recording to temporary directory.") + return + } - // A parent living in another conversation means this is a private reply - if let replyToToken = replyToMessage?.token, replyToToken != self.room.token { - metaData.replyToToken = replyToToken - } + if let temporaryMessage, NCDatabaseManager.sharedInstance().roomHasTalkCapability(.chatReferenceId, for: room) { + self.appendTemporaryMessage(temporaryMessage: temporaryMessage) + } - var upload = ChatFileUpload(localPath: destinationFilePath, - fileName: audioFileName, - room: self.room, - account: self.account) - upload.metadata = metaData - upload.referenceId = temporaryMessage.referenceId + var metaData = ChatFileUploadMetadata() + metaData.isVoiceMessage = isVoiceMessage + metaData.replyTo = replyToMessage?.messageId + metaData.threadId = self.thread?.threadId - self.upload(upload) - } else { - print("Temporary message could not be created") + // A parent living in another conversation means this is a private reply + if let replyToToken = replyToMessage?.token, replyToToken != self.room.token { + metaData.replyToToken = replyToToken } + + var upload = ChatFileUpload(localPath: destinationFilePath, + fileName: fileName, + room: self.room, + account: self.account) + upload.metadata = metaData + upload.referenceId = temporaryMessage?.referenceId + + self.upload(upload) } func upload(_ upload: ChatFileUpload) { @@ -2454,22 +2570,23 @@ import Toast // 'Pop' feedback (strong boom) AudioServicesPlaySystemSound(1520) - self.checkPermissionAndRecordVoiceMessage() + self.startRecordingForGesture() self.shouldLockInterfaceOrientation(lock: true) self.recordCancelled = false self.longPressStartingPoint = point self.voiceRecordingLockButton.alpha = 1 - self.setInputbarImage(UIImage(systemName: "mic"), for: self.rightButton) } else if gestureRecognizer.state == .ended { self.shouldLockInterfaceOrientation(lock: false) self.resetVoiceRecordingLockButton() if !isVoiceRecordingLocked { - if let recordingTime = self.recorder?.currentTime { + if let recordingTime = self.recorder?.currentTime, !self.isVideoGestureActive { // Mark record as cancelled if audio message is no longer than one second self.recordCancelled = recordingTime < 1 } - self.stopRecordingVoiceMessage() + + // Too short videos are dropped while finishing, like short voice messages + self.stopRecordingForGesture(send: !self.recordCancelled) print("Stop recording audio message") } } else if gestureRecognizer.state == .changed { @@ -2496,7 +2613,7 @@ import Toast // 'Cancelled' feedback (three sequential weak booms) AudioServicesPlaySystemSound(1521) self.recordCancelled = true - self.stopRecordingVoiceMessage() + self.stopRecordingForGesture(send: false) self.resetVoiceRecordingLockButton() } } @@ -2505,12 +2622,7 @@ import Toast let maxSlideY = 64.0 if slideY > maxSlideY, !self.recordCancelled { if !isVoiceRecordingLocked { - self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock"), for: .normal) - let offset = self.voiceMessageRecordingView?.getTimeCounted() - let intOffset = Int(offset!.magnitude) - showExpandedVoiceMessageRecordingView(offset: intOffset) - print("LOCKED") - isVoiceRecordingLocked = true + self.lockVoiceMessageRecording() } } } @@ -2519,10 +2631,27 @@ import Toast self.shouldLockInterfaceOrientation(lock: false) self.recordCancelled = false self.resetVoiceRecordingLockButton() - self.stopRecordingVoiceMessage() + self.stopRecordingForGesture(send: false) } } + /// Locks the recording, so it goes on without holding the button. A video keeps the row of the inputbar, so its + /// preview gets the height of a panel, a voice message gets the expanded panel. + private func lockVoiceMessageRecording() { + self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock"), for: .normal) + + if self.isVideoGestureActive { + self.showLockedVideoMessageActions() + } else { + let offset = self.voiceMessageRecordingView?.getTimeCounted() + let intOffset = Int(offset!.magnitude) + showExpandedVoiceMessageRecordingView(offset: intOffset) + } + + print("LOCKED") + isVoiceRecordingLocked = true + } + func shouldLockInterfaceOrientation(lock: Bool) { if let appDelegate = UIApplication.shared.delegate as? AppDelegate { appDelegate.shouldLockInterfaceOrientation = lock diff --git a/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift b/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift new file mode 100644 index 000000000..3b824eb22 --- /dev/null +++ b/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift @@ -0,0 +1,155 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import UIKit + +/// Code that is shared by everything in the app that captures with the camera: video messages and the in-app camera +enum CameraCaptureHelpers { + + /// The camera that is used for a position, if the device has one + static func camera(at position: AVCaptureDevice.Position) -> AVCaptureDevice? { + return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) + } + + /// Replaces the video input of a session with another one, the session needs to be in a configuration block. + /// Returns the input that is part of the session afterwards, which is the old one when the new one can not be added. + static func replaceVideoInput(_ oldInput: AVCaptureDeviceInput?, with newInput: AVCaptureDeviceInput, in session: AVCaptureSession) -> AVCaptureDeviceInput? { + if let oldInput { + session.removeInput(oldInput) + } + + if session.canAddInput(newInput) { + session.addInput(newInput) + return newInput + } + + if let oldInput, session.canAddInput(oldInput) { + session.addInput(oldInput) + } + + return oldInput + } + + /// Rotates the frames of a connection so they are upright in the given orientation of the interface + static func apply(_ orientation: UIInterfaceOrientation, to connection: AVCaptureConnection) { + if #available(iOS 17.0, *) { + let angle: CGFloat + + switch orientation { + case .landscapeRight: angle = 0 + case .landscapeLeft: angle = 180 + case .portraitUpsideDown: angle = 270 + default: angle = 90 + } + + if connection.isVideoRotationAngleSupported(angle) { + connection.videoRotationAngle = angle + } + } else if connection.isVideoOrientationSupported, + let videoOrientation = AVCaptureVideoOrientation(rawValue: orientation == .unknown ? UIInterfaceOrientation.portrait.rawValue : orientation.rawValue) { + connection.videoOrientation = videoOrientation + } + } + + /// The orientation of the interface a view is shown in, portrait as long as that is not known + static func interfaceOrientation(of view: UIView?) -> UIInterfaceOrientation { + let orientation = view?.window?.windowScene?.interfaceOrientation ?? .portrait + + return orientation == .unknown ? .portrait : orientation + } +} + +/// Live preview of a capture session, which follows the orientation of the interface +class CameraPreviewView: UIView { + + let previewLayer: AVCaptureVideoPreviewLayer + + /// The orientation the frames of the preview are rotated to + var interfaceOrientation: UIInterfaceOrientation { + didSet { + if oldValue != self.interfaceOrientation { + self.setNeedsLayout() + } + } + } + + init(session: AVCaptureSession, interfaceOrientation: UIInterfaceOrientation) { + self.interfaceOrientation = interfaceOrientation + self.previewLayer = AVCaptureVideoPreviewLayer(session: session) + + super.init(frame: .zero) + + self.backgroundColor = .black + self.clipsToBounds = true + + self.previewLayer.videoGravity = .resizeAspectFill + self.layer.addSublayer(self.previewLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.previewLayer.frame = self.bounds + + if let connection = self.previewLayer.connection { + CameraCaptureHelpers.apply(self.interfaceOrientation, to: connection) + } + } +} + +/// A capture session changes the shared audio session when a microphone is part of it. This remembers +/// the audio session from before, so voice messages, audio playback and calls are not affected afterwards. +final class CaptureAudioSessionRestorer { + + private var previousCategory: AVAudioSession.Category? + private var previousMode: AVAudioSession.Mode? + private var previousOptions: AVAudioSession.CategoryOptions? + + /// Set when another app took the audio device, the audio session is not ours to touch then + var audioDeviceTakenByAnotherClient = false + + /// Needs to be called before the capture session starts + func remember() { + let audioSession = AVAudioSession.sharedInstance() + self.previousCategory = audioSession.category + self.previousMode = audioSession.mode + self.previousOptions = audioSession.categoryOptions + } + + /// Deactivates the audio session and brings back the category from before + func restore() { + // During a call the audio session belongs to the call, and so it does when another app took the device + guard !self.audioSessionIsInUseElsewhere else { + self.previousMode = nil + self.previousOptions = nil + return + } + + let audioSession = AVAudioSession.sharedInstance() + + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + + if let category = self.previousCategory, + let mode = self.previousMode, + let options = self.previousOptions { + try? audioSession.setCategory(category, mode: mode, options: options) + } + + self.previousCategory = nil + self.previousMode = nil + self.previousOptions = nil + } + + private var audioSessionIsInUseElsewhere: Bool { + return self.audioDeviceTakenByAnotherClient + || NCRoomsManager.shared.callViewController != nil + || !CallKitManager.sharedInstance().calls.isEmpty + } +} diff --git a/NextcloudTalk/Chat/Camera/VideoMessagePreviewPlacement.swift b/NextcloudTalk/Chat/Camera/VideoMessagePreviewPlacement.swift new file mode 100644 index 000000000..58b8ffb9e --- /dev/null +++ b/NextcloudTalk/Chat/Camera/VideoMessagePreviewPlacement.swift @@ -0,0 +1,87 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +/// Where the live preview of a video message recording is shown in the chat. This is pure arithmetic, so it can be +/// tested without a screen. +enum VideoMessagePreviewPlacement { + + /// The gap kept free at every edge of the area + static let margin: CGFloat = 16 + + /// The part of the width of the area the preview takes at most + static let maxWidthFraction: CGFloat = 0.75 + + /// The longest side the preview has at most, so it does not grow without limit on an iPad + static let maxSide: CGFloat = 480 + + static let portraitAspect: CGFloat = 9.0 / 16.0 + static let landscapeAspect: CGFloat = 16.0 / 9.0 + + /// A margin may not eat the area: at most this part of the shorter side is kept free at every edge + private static let marginDivisor: CGFloat = 8 + + /// Width divided by height of the frame the viewer sees in the given orientation of the interface: + /// 9:16 in portrait, 16:9 in landscape. The orientation of the file that is recorded does not matter here. + static func frameAspect(for orientation: UIInterfaceOrientation) -> CGFloat { + return orientation.isLandscape ? self.landscapeAspect : self.portraitAspect + } + + /// Fits a frame of the given aspect ratio into the area and centers it there. A portrait frame is limited by the + /// width of the area and by its height, a landscape frame is fitted by the height when the area is low: the frame + /// is never distorted, only scaled down. An area without size gives an empty rect. + /// + /// - Parameters: + /// - area: the free space, between the top of the safe area and the top of the recording panel + /// - aspect: width divided by height of the frame, a value that is not usable is read as portrait + /// - margin: the gap kept free at every edge of the area, shrinks on a very small area + /// - maxWidthFraction: the part of the width of the area the preview takes at most + /// - maxSide: the longest side the preview has at most + static func frame(in area: CGRect, + aspect: CGFloat, + margin: CGFloat = VideoMessagePreviewPlacement.margin, + maxWidthFraction: CGFloat = VideoMessagePreviewPlacement.maxWidthFraction, + maxSide: CGFloat = VideoMessagePreviewPlacement.maxSide) -> CGRect { + // Not `area.width`, which is the absolute value for a rect with a negative size + let areaWidth = max(area.size.width, 0) + let areaHeight = max(area.size.height, 0) + let origin = area.origin + + guard areaWidth.isFinite, areaHeight.isFinite, areaWidth > 0, areaHeight > 0 else { + return CGRect(origin: origin, size: .zero) + } + + let safeAspect = (aspect.isFinite && aspect > 0) ? aspect : self.portraitAspect + let safeMargin = max(min(margin, min(areaWidth, areaHeight) / self.marginDivisor), 0) + + let boxWidth = max(min(areaWidth - 2 * safeMargin, areaWidth * maxWidthFraction), 0) + let boxHeight = max(areaHeight - 2 * safeMargin, 0) + + var width = boxWidth + var height = width / safeAspect + + if height > boxHeight { + height = boxHeight + width = height * safeAspect + } + + let longest = max(width, height) + + if longest > maxSide, longest > 0 { + let scale = maxSide / longest + width *= scale + height *= scale + } + + width = width.rounded() + height = height.rounded() + + return CGRect(x: origin.x + ((areaWidth - width) / 2).rounded(), + y: origin.y + ((areaHeight - height) / 2).rounded(), + width: width, + height: height) + } +} diff --git a/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift b/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift new file mode 100644 index 000000000..d1fe817e0 --- /dev/null +++ b/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift @@ -0,0 +1,409 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import UIKit + +/// Records a short video with sound into an MP4 file (H.264 / AAC), in the orientation of the interface it started in. +/// +/// This deliberately does not use `AVCaptureMovieFileOutput`: removing the video input of the session to +/// switch between the front and the back camera tears down the connection of that output, which ends +/// the recording. With `AVCaptureVideoDataOutput` and `AVCaptureAudioDataOutput` feeding an +/// `AVAssetWriter`, the writer is not part of the capture graph and keeps running while the camera is swapped. +final class VideoMessageRecorder: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate { + + /// A video message ends automatically after this time + static let maxDuration: TimeInterval = 60 + + /// Pixel size of the longer and the shorter side of the video, from the 1280x720 capture preset + private static let videoLongSide = 1280 + private static let videoShortSide = 720 + + /// Orientation of the interface when the recording started, it is fixed for the whole recording + let interfaceOrientation: UIInterfaceOrientation + + var isLandscape: Bool { + return self.interfaceOrientation.isLandscape + } + + let session = AVCaptureSession() + + /// Called on the main queue when the recording can not go on (session interrupted, runtime error) + var onFailure: (() -> Void)? + + /// Called on the main queue after the camera was switched, as the connection of a preview is created again then + var onCameraSwitched: (() -> Void)? + + /// Time of the start of the recording, only set on the main queue + private(set) var startDate: Date? + + /// Position of the camera in use, only changed on the main queue + private(set) var cameraPosition: AVCaptureDevice.Position = .front + + var elapsed: TimeInterval { + guard let startDate else { return 0 } + return Date().timeIntervalSince(startDate) + } + + let outputURL: URL + + // Session configuration and start/stop can block, so they are kept off the main queue + private let sessionQueue = DispatchQueue(label: "\(groupIdentifier).videoMessage.session") + + // Sample buffers of both outputs arrive on this queue, as does all access to the writer + private let sampleQueue = DispatchQueue(label: "\(groupIdentifier).videoMessage.samples") + + private let videoOutput = AVCaptureVideoDataOutput() + private let audioOutput = AVCaptureAudioDataOutput() + private var videoDeviceInput: AVCaptureDeviceInput? + + private var writer: AVAssetWriter? + private var writerVideoInput: AVAssetWriterInput? + private var writerAudioInput: AVAssetWriterInput? + private var sessionStartTime: CMTime? + private var isWriting = false + + // Brings back the audio session, as the capture session changes it + private let audioSessionRestorer = CaptureAudioSessionRestorer() + + init(interfaceOrientation: UIInterfaceOrientation) { + self.interfaceOrientation = interfaceOrientation + + // Every recorder has a file of its own, as the file of a previous one might still be finished + self.outputURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("video-message-recording-\(UUID().uuidString).mp4") + + super.init() + + NotificationCenter.default.addObserver(self, selector: #selector(sessionWasInterrupted), name: AVCaptureSession.wasInterruptedNotification, object: self.session) + NotificationCenter.default.addObserver(self, selector: #selector(sessionRuntimeError), name: AVCaptureSession.runtimeErrorNotification, object: self.session) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Start and stop + + /// Starts the camera and the recording. `completion` is called on the main queue. + func start(completion: @escaping (Bool) -> Void) { + self.audioSessionRestorer.remember() + + let initialPosition = self.cameraPosition + + self.sessionQueue.async { + guard self.configureSession(position: initialPosition), + self.sampleQueue.sync(execute: { self.prepareWriter() }) + else { + DispatchQueue.main.async { + self.audioSessionRestorer.restore() + completion(false) + } + return + } + + self.session.startRunning() + + DispatchQueue.main.async { + self.startDate = Date() + completion(true) + } + } + } + + /// Stops the camera and finishes the file. `completion` is called on the main queue, with the + /// file when `keepFile` is set and the file is complete. + func stop(keepFile: Bool, completion: ((URL?) -> Void)? = nil) { + self.sessionQueue.async { + self.session.stopRunning() + + self.sampleQueue.async { + self.isWriting = false + + let finish: (URL?) -> Void = { url in + DispatchQueue.main.async { + self.audioSessionRestorer.restore() + completion?(url) + } + } + + guard let writer = self.writer else { + finish(nil) + return + } + + self.writer = nil + + guard keepFile, writer.status == .writing else { + // Cancelling is only allowed once the writer has started, which needs a first frame + if writer.status == .writing { + writer.cancelWriting() + } + + try? FileManager.default.removeItem(at: self.outputURL) + finish(nil) + return + } + + self.writerVideoInput?.markAsFinished() + self.writerAudioInput?.markAsFinished() + + writer.finishWriting { + if writer.status == .completed { + finish(self.outputURL) + } else { + try? FileManager.default.removeItem(at: self.outputURL) + finish(nil) + } + } + } + } + } + + // MARK: - Camera switch + + /// Switches between the front and the back camera, without interrupting the recording + func switchCamera() { + let newPosition: AVCaptureDevice.Position = self.cameraPosition == .front ? .back : .front + + guard CameraCaptureHelpers.camera(at: newPosition) != nil else { return } + + self.cameraPosition = newPosition + + self.sessionQueue.async { + guard let newDevice = CameraCaptureHelpers.camera(at: newPosition), + let newInput = try? AVCaptureDeviceInput(device: newDevice) + else { return } + + self.session.beginConfiguration() + + self.videoDeviceInput = CameraCaptureHelpers.replaceVideoInput(self.videoDeviceInput, with: newInput, in: self.session) + + // The connection of the output is created again with the new input + self.configureVideoConnection() + + self.session.commitConfiguration() + + DispatchQueue.main.async { self.onCameraSwitched?() } + } + } + + var canSwitchCamera: Bool { + return CameraCaptureHelpers.camera(at: .front) != nil && CameraCaptureHelpers.camera(at: .back) != nil + } + + // MARK: - Configuration + + private func configureSession(position: AVCaptureDevice.Position) -> Bool { + guard let camera = CameraCaptureHelpers.camera(at: position) ?? CameraCaptureHelpers.camera(at: position == .front ? .back : .front), + let microphone = AVCaptureDevice.default(for: .audio), + let cameraInput = try? AVCaptureDeviceInput(device: camera), + let microphoneInput = try? AVCaptureDeviceInput(device: microphone) + else { return false } + + self.session.beginConfiguration() + + if self.session.canSetSessionPreset(.hd1280x720) { + self.session.sessionPreset = .hd1280x720 + } + + // Keeps the camera available while the app is shown next to another one on an iPad + if self.session.isMultitaskingCameraAccessSupported { + self.session.isMultitaskingCameraAccessEnabled = true + } + + self.videoOutput.alwaysDiscardsLateVideoFrames = true + self.videoOutput.setSampleBufferDelegate(self, queue: self.sampleQueue) + self.audioOutput.setSampleBufferDelegate(self, queue: self.sampleQueue) + + guard self.session.canAddInput(cameraInput), self.session.canAddInput(microphoneInput), + self.session.canAddOutput(self.videoOutput), self.session.canAddOutput(self.audioOutput) + else { + self.session.commitConfiguration() + return false + } + + self.session.addInput(cameraInput) + self.session.addInput(microphoneInput) + self.session.addOutput(self.videoOutput) + self.session.addOutput(self.audioOutput) + self.videoDeviceInput = cameraInput + + self.configureVideoConnection() + + self.session.commitConfiguration() + + return true + } + + /// The connection is reset by the session whenever the input changes, so this needs to run after every + /// change of the camera. The recording is not mirrored, like in the system camera, while the preview of + /// the front camera is. + private func configureVideoConnection() { + guard let connection = self.videoOutput.connection(with: .video) else { return } + + CameraCaptureHelpers.apply(self.interfaceOrientation, to: connection) + + if connection.isVideoMirroringSupported { + connection.automaticallyAdjustsVideoMirroring = false + connection.isVideoMirrored = false + } + } + + /// Needs to be called on the sample queue + private func prepareWriter() -> Bool { + try? FileManager.default.removeItem(at: self.outputURL) + + guard let writer = try? AVAssetWriter(outputURL: self.outputURL, fileType: .mp4) else { return false } + + // Moves the metadata to the front of the file, so it can be played while it is being downloaded + writer.shouldOptimizeForNetworkUse = true + + let videoWidth = self.isLandscape ? Self.videoLongSide : Self.videoShortSide + let videoHeight = self.isLandscape ? Self.videoShortSide : Self.videoLongSide + + let videoSettings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264.rawValue, + AVVideoWidthKey: videoWidth, + AVVideoHeightKey: videoHeight, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: 2_500_000, + AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel, + AVVideoMaxKeyFrameIntervalKey: 60 + ] as [String: Any] + ] + + let audioSettings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 64000 + ] + + let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + videoInput.expectsMediaDataInRealTime = true + + let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) + audioInput.expectsMediaDataInRealTime = true + + guard writer.canAdd(videoInput), writer.canAdd(audioInput) else { return false } + + writer.add(videoInput) + writer.add(audioInput) + + self.writer = writer + self.writerVideoInput = videoInput + self.writerAudioInput = audioInput + self.sessionStartTime = nil + self.isWriting = true + + return true + } + + // MARK: - Sample buffers + + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + guard self.isWriting, let writer = self.writer, CMSampleBufferDataIsReady(sampleBuffer) else { return } + + let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + let isVideo = output === self.videoOutput + + if writer.status == .unknown { + // The file starts with the first video frame, audio before it is dropped + guard isVideo else { return } + + guard writer.startWriting() else { + self.isWriting = false + DispatchQueue.main.async { self.onFailure?() } + return + } + + writer.startSession(atSourceTime: timestamp) + self.sessionStartTime = timestamp + } + + guard writer.status == .writing, let sessionStartTime = self.sessionStartTime else { + if writer.status == .failed { + self.isWriting = false + DispatchQueue.main.async { self.onFailure?() } + } + + return + } + + if isVideo { + if let input = self.writerVideoInput, input.isReadyForMoreMediaData { + input.append(sampleBuffer) + } + } else if CMTimeCompare(timestamp, sessionStartTime) >= 0, + let input = self.writerAudioInput, input.isReadyForMoreMediaData { + input.append(sampleBuffer) + } + } + + // MARK: - Notifications + + @objc private func sessionWasInterrupted(_ notification: Notification) { + if let reasonValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int, + AVCaptureSession.InterruptionReason(rawValue: reasonValue) == .audioDeviceInUseByAnotherClient { + self.audioSessionRestorer.audioDeviceTakenByAnotherClient = true + } + + DispatchQueue.main.async { self.onFailure?() } + } + + @objc private func sessionRuntimeError(_ notification: Notification) { + DispatchQueue.main.async { self.onFailure?() } + } +} + +/// Live preview of the camera while recording a video message, with a button to switch the camera +final class VideoMessagePreviewView: CameraPreviewView { + + var onSwitchCamera: (() -> Void)? + + private lazy var switchCameraButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: "arrow.triangle.2.circlepath.camera"), for: .normal) + button.tintColor = .white + button.backgroundColor = UIColor.black.withAlphaComponent(0.4) + button.layer.cornerRadius = 18 + button.accessibilityLabel = NSLocalizedString("Switch camera", comment: "") + button.addAction(UIAction { [weak self] _ in self?.onSwitchCamera?() }, for: .touchUpInside) + + return button + }() + + init(session: AVCaptureSession, interfaceOrientation: UIInterfaceOrientation, showsSwitchCameraButton: Bool) { + super.init(session: session, interfaceOrientation: interfaceOrientation) + + self.layer.cornerRadius = 16 + self.layer.cornerCurve = .continuous + + self.addSubview(self.switchCameraButton) + self.switchCameraButton.isHidden = !showsSwitchCameraButton + + NSLayoutConstraint.activate([ + self.switchCameraButton.widthAnchor.constraint(equalToConstant: 36), + self.switchCameraButton.heightAnchor.constraint(equalToConstant: 36), + self.switchCameraButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), + self.switchCameraButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8) + ]) + + // Tapping the preview switches the camera as well, as the button can not be reached while the + // finger is still holding the record button + if showsSwitchCameraButton { + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(previewTapped))) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func previewTapped() { + self.onSwitchCamera?() + } +} diff --git a/NextcloudTalk/Chat/Chat views/VoiceMessageRecordingView.swift b/NextcloudTalk/Chat/Chat views/VoiceMessageRecordingView.swift index df130c370..1e4811dc8 100644 --- a/NextcloudTalk/Chat/Chat views/VoiceMessageRecordingView.swift +++ b/NextcloudTalk/Chat/Chat views/VoiceMessageRecordingView.swift @@ -119,6 +119,28 @@ class VoiceMessageRecordingView: UIView { leftBackgroundView.isHidden = true } + /// Replaces the hint to slide to cancel with a button, as the finger no longer holds the record button + /// once the recording is locked + func showCancelButton(action: @escaping () -> Void) { + hintContainerView.isHidden = true + + let cancelButton = UIButton(type: .system) + cancelButton.translatesAutoresizingMaskIntoConstraints = false + cancelButton.setTitle(NSLocalizedString("Cancel", comment: ""), for: .normal) + cancelButton.titleLabel?.font = .preferredFont(forTextStyle: .body) + cancelButton.addAction(UIAction { _ in action() }, for: .touchUpInside) + + contentView.addSubview(cancelButton) + + cancelButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + NSLayoutConstraint.activate([ + cancelButton.leadingAnchor.constraint(greaterThanOrEqualTo: recordingTimeLabel.trailingAnchor, constant: 8), + cancelButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + cancelButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + } + /// Gives the recording view a glass capsule of its own, so it can take the place of the input field @available(iOS 26.0, *) public func useGlassBackground(cornerRadius: CGFloat) { diff --git a/NextcloudTalk/Chat/ChatViewController.swift b/NextcloudTalk/Chat/ChatViewController.swift index f9da8079d..2d4f8f764 100644 --- a/NextcloudTalk/Chat/ChatViewController.swift +++ b/NextcloudTalk/Chat/ChatViewController.swift @@ -998,7 +998,7 @@ import SwiftUI oooView.setupAbsence(withData: absenceData, inRoom: self.room) oooView.alpha = 0 - self.view.addSubview(oooView) + self.addBelowVideoMessageScrim(oooView) NSLayoutConstraint.activate([ oooView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), @@ -1041,7 +1041,7 @@ import SwiftUI view.setupPinnedMessage(withMessage: message, inRoom: self.room) view.alpha = 0 - self.view.addSubview(view) + self.addBelowVideoMessageScrim(view) NSLayoutConstraint.activate([ view.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), @@ -1223,8 +1223,7 @@ import SwiftUI // If in offline mode, we don't want to show the voice button if !offlineMode, !canPress, !presentedInCall, - NCDatabaseManager.sharedInstance().roomHasTalkCapability(.voiceMessage, for: room), - !room.isFederated { + self.isVoiceMessageRecordingAvailable || self.isVideoMessageRecordingAvailable { self.showVoiceMessageRecordButton() return true diff --git a/NextcloudTalk/Info.plist b/NextcloudTalk/Info.plist index d3f03ab8e..2987f9827 100644 --- a/NextcloudTalk/Info.plist +++ b/NextcloudTalk/Info.plist @@ -53,13 +53,13 @@ LSSupportsOpeningDocumentsInPlace NSCameraUsageDescription - Camera is used in video calls + Camera is used in video calls and to record videos to share in chats NSContactsUsageDescription Contacts access is needed to match your address book contacts with contacts in your server NSLocationWhenInUseUsageDescription It is needed to share your location with others NSMicrophoneUsageDescription - Microphone is used in voice and video calls + Microphone is used in voice and video calls and to record videos to share in chats NSPhotoLibraryUsageDescription Photo Library access is needed to share pictures and videos from your device NSSpeechRecognitionUsageDescription diff --git a/NextcloudTalk/Settings/NCUserDefaults.swift b/NextcloudTalk/Settings/NCUserDefaults.swift index 275aadb42..27d7ee9c7 100644 --- a/NextcloudTalk/Settings/NCUserDefaults.swift +++ b/NextcloudTalk/Settings/NCUserDefaults.swift @@ -12,6 +12,7 @@ public class NCUserDefaults: NSObject { private static let includeCallsInRecentsKey = "ncIncludeCallsInRecents" private static let preferredCallViewModeKey = "ncPreferredCallViewMode" private static let speakerViewStripeHiddenKey = "ncSpeakerViewStripeHidden" + private static let preferredRecordButtonModeKey = "ncPreferredRecordButtonMode" public static func setPreferredCameraFlashMode(_ flashMode: Int) { UserDefaults.standard.set(flashMode, forKey: preferredCameraFlashModeKey) @@ -52,6 +53,14 @@ public class NCUserDefaults: NSObject { return UserDefaults.standard.string(forKey: preferredCallViewModeKey) } + public static func setPreferredRecordButtonMode(_ mode: String) { + UserDefaults.standard.set(mode, forKey: preferredRecordButtonModeKey) + } + + public static func preferredRecordButtonMode() -> String? { + return UserDefaults.standard.string(forKey: preferredRecordButtonModeKey) + } + public static func setSpeakerViewStripeHidden(_ hidden: Bool) { UserDefaults.standard.set(hidden, forKey: speakerViewStripeHiddenKey) } diff --git a/NextcloudTalk/en.lproj/InfoPlist.strings b/NextcloudTalk/en.lproj/InfoPlist.strings index 7b9d59999..971a1491a 100644 --- a/NextcloudTalk/en.lproj/InfoPlist.strings +++ b/NextcloudTalk/en.lproj/InfoPlist.strings @@ -1,5 +1,5 @@ -NSCameraUsageDescription = "Camera is used in video calls"; -NSMicrophoneUsageDescription = "Microphone is used in voice and video calls"; +NSCameraUsageDescription = "Camera is used in video calls and to record videos to share in chats"; +NSMicrophoneUsageDescription = "Microphone is used in voice and video calls and to record videos to share in chats"; NSPhotoLibraryUsageDescription = "Photo Library access is needed to share pictures and videos from your device"; NSContactsUsageDescription = "Contacts access is needed to match your address book contacts with contacts in your server"; NSLocationWhenInUseUsageDescription = "It is needed to share your location with others"; diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index fb80b98c2..60410d63a 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -2070,6 +2070,9 @@ /* No comment provided by engineer. */ "Recent threads" = "Recent threads"; +/* No comment provided by engineer. */ +"Record video message" = "Record video message"; + /* No comment provided by engineer. */ "Record voice message" = "Record voice message"; @@ -2446,6 +2449,9 @@ /* No comment provided by engineer. */ "Switch account" = "Switch account"; +/* No comment provided by engineer. */ +"Switch camera" = "Switch camera"; + /* No comment provided by engineer. */ "Switch to voice-only call" = "Switch to voice-only call"; @@ -2467,12 +2473,24 @@ /* 'Talk to Alice' - start a conversation with a user */ "Talk to %@" = "Talk to %@"; +/* No comment provided by engineer. */ +"Tap and hold to record a video message" = "Tap and hold to record a video message"; + +/* No comment provided by engineer. */ +"Tap and hold to record a video, release the button to send it." = "Tap and hold to record a video, release the button to send it."; + +/* No comment provided by engineer. */ +"Tap and hold to record a video. Tap to switch to voice messages." = "Tap and hold to record a video. Tap to switch to voice messages."; + /* No comment provided by engineer. */ "Tap and hold to record a voice message" = "Tap and hold to record a voice message"; /* No comment provided by engineer. */ "Tap and hold to record a voice message, release the button to send it." = "Tap and hold to record a voice message, release the button to send it."; +/* No comment provided by engineer. */ +"Tap and hold to record a voice message. Tap to switch to video messages." = "Tap and hold to record a voice message. Tap to switch to video messages."; + /* No comment provided by engineer. */ "Test failed" = "Test failed"; @@ -2725,6 +2743,9 @@ /* Join a call in video call mode */ "Video call" = "Video call"; +/* No comment provided by engineer. */ +"Video message could not be recorded" = "Video message could not be recorded"; + /* No comment provided by engineer. */ "Video quality" = "Video quality"; diff --git a/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift b/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift new file mode 100644 index 000000000..fcf6b7028 --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift @@ -0,0 +1,194 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit +import XCTest +@testable import NextcloudTalk + +final class UnitVideoMessagePreviewPlacementTest: XCTestCase { + + private let portrait = VideoMessagePreviewPlacement.portraitAspect + private let landscape = VideoMessagePreviewPlacement.landscapeAspect + + private func place(_ width: CGFloat, _ height: CGFloat, aspect: CGFloat, origin: CGPoint = .zero) -> CGRect { + return VideoMessagePreviewPlacement.frame(in: CGRect(origin: origin, size: CGSize(width: width, height: height)), aspect: aspect) + } + + private func assertInside(_ rect: CGRect, _ area: CGRect, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertGreaterThanOrEqual(rect.minX, area.minX, file: file, line: line) + XCTAssertGreaterThanOrEqual(rect.minY, area.minY, file: file, line: line) + XCTAssertLessThanOrEqual(rect.maxX, area.maxX, file: file, line: line) + XCTAssertLessThanOrEqual(rect.maxY, area.maxY, file: file, line: line) + } + + private func assertAspect(_ rect: CGRect, _ aspect: CGFloat, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(rect.width / rect.height, aspect, accuracy: 0.03, file: file, line: line) + } + + // MARK: - Size + + func testPortraitTakesThreeQuartersOfTheWidthAndKeepsTheAspect() throws { + let rect = self.place(348, 560, aspect: self.portrait) + + XCTAssertEqual(rect.width, 261) + XCTAssertEqual(rect.height, 464) + self.assertAspect(rect, self.portrait) + self.assertInside(rect, CGRect(x: 0, y: 0, width: 348, height: 560)) + } + + func testPreviewIsCenteredInTheAreaAndFollowsItsOrigin() throws { + let area = CGRect(x: 10, y: 100, width: 348, height: 560) + let rect = VideoMessagePreviewPlacement.frame(in: area, aspect: self.portrait) + + XCTAssertEqual(rect.midX, area.midX, accuracy: 1) + XCTAssertEqual(rect.midY, area.midY, accuracy: 1) + } + + func testPortraitInALowAreaIsFittedByTheHeightWithMargins() throws { + let rect = self.place(348, 400, aspect: self.portrait) + + // 400 - 2 * 16 + XCTAssertEqual(rect.height, 368) + XCTAssertEqual(rect.width, 207) + self.assertAspect(rect, self.portrait) + } + + func testLandscapeOnAPhoneInLandscapeIsFittedByTheHeightAndNotSquashed() throws { + // Landscape iPhone: the area between the navigation bar and the recording panel is low + let area = CGRect(x: 0, y: 50, width: 700, height: 200) + let rect = VideoMessagePreviewPlacement.frame(in: area, aspect: self.landscape) + + XCTAssertEqual(rect.height, 168) + XCTAssertEqual(rect.width, 299) + self.assertAspect(rect, self.landscape) + self.assertInside(rect, area.insetBy(dx: 16, dy: 16)) + } + + func testLandscapeInAPortraitAreaIsFittedByTheWidth() throws { + let rect = self.place(348, 560, aspect: self.landscape) + + XCTAssertEqual(rect.width, 261) + XCTAssertEqual(rect.height, 147) + self.assertAspect(rect, self.landscape) + } + + func testNarrowAreaKeepsTheAspectAndTheMargin() throws { + let rect = self.place(240, 520, aspect: self.portrait) + + // 75 % of 240, the margins would allow 208 + XCTAssertEqual(rect.width, 180) + XCTAssertEqual(rect.height, 320) + self.assertAspect(rect, self.portrait) + } + + func testMarginLimitsTheWidthWhenItIsNarrowerThanTheFraction() throws { + let rect = VideoMessagePreviewPlacement.frame(in: CGRect(x: 0, y: 0, width: 200, height: 520), aspect: self.landscape, maxWidthFraction: 0.95) + + // 200 - 2 * 16 is less than 95 % of 200 + XCTAssertEqual(rect.width, 168) + self.assertAspect(rect, self.landscape) + } + + func testVeryLowAreaStillGivesAPreviewInsideTheArea() throws { + let area = CGRect(x: 0, y: 0, width: 348, height: 40) + let rect = VideoMessagePreviewPlacement.frame(in: area, aspect: self.portrait) + + XCTAssertGreaterThan(rect.height, 0) + XCTAssertGreaterThan(rect.width, 0) + self.assertAspect(rect, self.portrait) + self.assertInside(rect, area) + + let landscapeArea = CGRect(x: 0, y: 0, width: 700, height: 24) + let landscapeRect = VideoMessagePreviewPlacement.frame(in: landscapeArea, aspect: self.landscape) + + XCTAssertGreaterThan(landscapeRect.height, 0) + self.assertAspect(landscapeRect, self.landscape) + self.assertInside(landscapeRect, landscapeArea) + } + + func testIPadIsCappedByTheLongestSide() throws { + let portraitRect = self.place(1000, 1000, aspect: self.portrait) + + XCTAssertEqual(portraitRect.height, 480) + XCTAssertEqual(portraitRect.width, 270) + self.assertAspect(portraitRect, self.portrait) + + let landscapeRect = self.place(1100, 700, aspect: self.landscape) + + XCTAssertEqual(landscapeRect.width, 480) + XCTAssertEqual(landscapeRect.height, 270) + self.assertAspect(landscapeRect, self.landscape) + } + + // MARK: - Areas without size + + func testEmptyAreaGivesAnEmptyRect() throws { + XCTAssertEqual(self.place(0, 0, aspect: self.portrait).size, .zero) + XCTAssertEqual(self.place(348, 0, aspect: self.portrait).size, .zero) + XCTAssertEqual(self.place(0, 560, aspect: self.landscape).size, .zero) + } + + func testNegativeAreaGivesAnEmptyRect() throws { + // The area is the space above the panel, which is negative when the panel is higher than the chat + XCTAssertEqual(self.place(348, -120, aspect: self.portrait).size, .zero) + XCTAssertEqual(self.place(-5, 560, aspect: self.portrait).size, .zero) + XCTAssertEqual(self.place(-5, -5, aspect: self.landscape).size, .zero) + } + + func testAspectThatIsNotUsableIsReadAsPortrait() throws { + let expected = self.place(348, 560, aspect: self.portrait) + + XCTAssertEqual(self.place(348, 560, aspect: .nan), expected) + XCTAssertEqual(self.place(348, 560, aspect: 0), expected) + XCTAssertEqual(self.place(348, 560, aspect: -1), expected) + XCTAssertEqual(self.place(348, 560, aspect: .infinity), expected) + } + + // MARK: - Aspect of the orientation + + func testFrameAspectOfTheOrientations() throws { + let expected: [(UIInterfaceOrientation, CGFloat)] = [ + (.portrait, 9.0 / 16.0), + (.portraitUpsideDown, 9.0 / 16.0), + (.landscapeLeft, 16.0 / 9.0), + (.landscapeRight, 16.0 / 9.0), + (.unknown, 9.0 / 16.0) + ] + + for (orientation, aspect) in expected { + XCTAssertEqual(VideoMessagePreviewPlacement.frameAspect(for: orientation), aspect, accuracy: 0.0001, "orientation \(orientation.rawValue)") + } + } + + func testPlacementAfterARotationSwitchesBetweenThePortraitAndTheLandscapeShape() throws { + let inPortrait = self.place(390, 600, aspect: VideoMessagePreviewPlacement.frameAspect(for: .portrait)) + let inLandscape = self.place(750, 250, aspect: VideoMessagePreviewPlacement.frameAspect(for: .landscapeLeft)) + + XCTAssertLessThan(inPortrait.width, inPortrait.height) + XCTAssertGreaterThan(inLandscape.width, inLandscape.height) + } + + // MARK: - Compact recording panel (iPhone 390x844 and 844x390, areas from the estimates in the report) + + func testLandscapePhoneGetsMoreWhenThePanelIsTheHeightOfTheInputbar() throws { + // Between the navigation bar (32) and the top of the inputbar (317), safe area left and right 47 + let compact = VideoMessagePreviewPlacement.frame(in: CGRect(x: 47, y: 32, width: 750, height: 285), aspect: self.landscape) + // Between the navigation bar and the top of the big panel of the first version (about 282) + let big = VideoMessagePreviewPlacement.frame(in: CGRect(x: 47, y: 32, width: 750, height: 250), aspect: self.landscape) + + XCTAssertEqual(compact, CGRect(x: 197, y: 48, width: 450, height: 253)) + XCTAssertEqual(big, CGRect(x: 228, y: 48, width: 388, height: 218)) + XCTAssertGreaterThan(compact.height, big.height) + self.assertAspect(compact, self.landscape) + } + + func testPortraitPhoneIsCappedByTheLongestSideWithTheCompactPanel() throws { + // Between the navigation bar (47 + 44) and the top of the inputbar (758) + let compact = VideoMessagePreviewPlacement.frame(in: CGRect(x: 0, y: 91, width: 390, height: 667), aspect: self.portrait) + + XCTAssertEqual(compact, CGRect(x: 60, y: 185, width: 270, height: 480)) + XCTAssertLessThanOrEqual(compact.maxY, 758 - 16) + } +}