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

Async voice messages #415

Merged
merged 32 commits into from
Dec 20, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bf513a7
Initial work
martinmitrevski Dec 11, 2023
f4c807c
Added waveform
martinmitrevski Dec 12, 2023
70d113a
Fixed the waveform
martinmitrevski Dec 12, 2023
15534b7
Added seeking
martinmitrevski Dec 12, 2023
2309a74
UI work around the composer
martinmitrevski Dec 12, 2023
ebae483
Recording work start
martinmitrevski Dec 13, 2023
3be4e62
Merge branch 'main' of https://github.com/GetStream/stream-chat-swift…
martinmitrevski Dec 14, 2023
87e30f7
Initial sending implemenentation
martinmitrevski Dec 14, 2023
066fcf7
Work on attachment previews
martinmitrevski Dec 15, 2023
10534b8
Flow improvements
martinmitrevski Dec 15, 2023
8ca4d90
Added tip view
martinmitrevski Dec 15, 2023
b9356a5
Small bugfix
martinmitrevski Dec 15, 2023
e49902f
Refactoring and fixes
martinmitrevski Dec 15, 2023
9c90fc9
Started adding tests
martinmitrevski Dec 15, 2023
e451127
Added playback rate
martinmitrevski Dec 18, 2023
60fe0ee
UI adjustments
martinmitrevski Dec 18, 2023
b1c3718
Added config
martinmitrevski Dec 18, 2023
da3c5f4
Added factory methods
martinmitrevski Dec 18, 2023
28e1045
Added snapshot tests
martinmitrevski Dec 18, 2023
a5ebe74
Small improvements
martinmitrevski Dec 19, 2023
21fe6fb
Lint errors
martinmitrevski Dec 19, 2023
49c36d7
Updated CHANGELOG
martinmitrevski Dec 19, 2023
65970c0
Updated snapshots
martinmitrevski Dec 19, 2023
8fd872a
Fixed bugs
martinmitrevski Dec 19, 2023
de8a7cc
PR remarks
martinmitrevski Dec 19, 2023
40237e1
Bug fixes
martinmitrevski Dec 20, 2023
1b1f544
Auto playing of voice attachments
martinmitrevski Dec 20, 2023
d8e13aa
Bug fixes
martinmitrevski Dec 20, 2023
2df6b71
Added test for recording preview
martinmitrevski Dec 20, 2023
d117be6
Bug fixes
martinmitrevski Dec 20, 2023
5ea98a7
Updated snapshots
martinmitrevski Dec 20, 2023
88e486a
Fixed slow mode test
martinmitrevski Dec 20, 2023
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### ✅ Added
- Recording of async voice messages
- Rendering and playing async voice messages

### 🔄 Changed

# [4.45.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.45.0)
Expand Down
3 changes: 2 additions & 1 deletion DemoAppSwiftUI/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
#endif

let utils = Utils(
messageListConfig: MessageListConfig(dateIndicatorPlacement: .messageList)
messageListConfig: MessageListConfig(dateIndicatorPlacement: .messageList),
composerConfig: ComposerConfig(isVoiceRecordingEnabled: true)
)
streamChat = StreamChat(chatClient: chatClient, utils: utils)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,10 +686,18 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
scrolledId = messages.first?.messageId
}

private func cleanupAudioPlayer() {
utils.audioPlayer.seek(to: 0)
utils.audioPlayer.updateRate(.normal)
utils.audioPlayer.stop()
utils._audioPlayer = nil
}

deinit {
messageCachingUtils.clearCache()
if messageController == nil {
utils.channelControllerFactory.clearCurrentController()
cleanupAudioPlayer()
ImageCache.shared.trim(toCost: utils.messageListConfig.cacheSizeOnChatDismiss)
if !channelDataSource.hasLoadedAllNextMessages {
channelDataSource.loadFirstPage { _ in }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SwiftUI
/// Config for customizing the composer.
public struct ComposerConfig {

public var isVoiceRecordingEnabled: Bool
public var inputViewMinHeight: CGFloat
public var inputViewMaxHeight: CGFloat
public var inputViewCornerRadius: CGFloat
Expand All @@ -19,6 +20,7 @@ public struct ComposerConfig {
public var attachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload]

public init(
isVoiceRecordingEnabled: Bool = false,
inputViewMinHeight: CGFloat = 38,
inputViewMaxHeight: CGFloat = 76,
inputViewCornerRadius: CGFloat = 20,
Expand All @@ -39,6 +41,7 @@ public struct ComposerConfig {
self.attachmentPayloadConverter = attachmentPayloadConverter
self.gallerySupportedTypes = gallerySupportedTypes
self.inputPaddingsConfig = inputPaddingsConfig
self.isVoiceRecordingEnabled = isVoiceRecordingEnabled
}

public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] = { message in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,17 @@ public struct CustomAttachment: Identifiable, Equatable {
self.content = content
}
}

/// Represents an added voice recording.
public struct AddedVoiceRecording: Identifiable, Equatable {
public var id: String {
url.absoluteString
}

/// The URL of the recording.
public let url: URL
/// The duration of the recording.
public let duration: TimeInterval
/// The waveform of the recording.
public let waveform: [Float]
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
shouldScroll: viewModel.inputComposerShouldScroll,
removeAttachmentWithId: viewModel.removeAttachment(with:)
)
.environmentObject(viewModel)
.alert(isPresented: $viewModel.attachmentSizeExceeded) {
Alert(
title: Text(L10n.Attachment.MaxSize.title),
Expand All @@ -102,11 +103,32 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
onMessageSent()
}
}
.environmentObject(viewModel)
.alert(isPresented: $viewModel.errorShown) {
Alert.defaultErrorAlert
}
}
.padding(.all, 8)
.opacity(viewModel.recordingState.showsComposer ? 1 : 0)
.overlay(
ZStack {
if case let .recording(location) = viewModel.recordingState {
factory.makeComposerRecordingView(
viewModel: viewModel,
gestureLocation: location
)
} else if viewModel.recordingState == .locked || viewModel.recordingState == .stopped {
factory.makeComposerRecordingLockedView(viewModel: viewModel)
.frame(height: 80)
} else if viewModel.recordingState == .showingTip {
factory.makeComposerRecordingTipView()
.offset(y: -composerHeight + 12)
} else {
EmptyView()
}
}
)
.frame(height: viewModel.recordingState.showsComposer ? nil : 80)
martinmitrevski marked this conversation as resolved.
Show resolved Hide resolved

if viewModel.sendInChannelShown {
factory.makeSendInChannelView(
Expand Down Expand Up @@ -194,6 +216,8 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
/// View for the composer's input (text and media).
public struct ComposerInputView<Factory: ViewFactory>: View {

@EnvironmentObject var viewModel: MessageComposerViewModel

@Injected(\.colors) private var colors
@Injected(\.fonts) private var fonts
@Injected(\.images) private var images
Expand Down Expand Up @@ -292,6 +316,15 @@ public struct ComposerInputView<Factory: ViewFactory>: View {
)
.padding(.trailing, 8)
}

if !viewModel.addedVoiceRecordings.isEmpty {
AddedVoiceRecordingsView(
addedVoiceRecordings: viewModel.addedVoiceRecordings,
onDiscardAttachment: removeAttachmentWithId
)
.padding(.trailing, 8)
.padding(.top, 8)
}

if !addedCustomAttachments.isEmpty {
factory.makeCustomAttachmentPreviewView(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// Copyright © 2023 Stream.io Inc. All rights reserved.
//

import StreamChat
import SwiftUI

public struct AudioRecordingInfo: Equatable {
/// The waveform of the recording.
public var waveform: [Float]
/// The duration of the recording.
public var duration: TimeInterval

mutating func update(with entry: Float, duration: TimeInterval) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this one needed? The properties are var so probably we can update them in calling place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible to do it in place, but I think it's cleaner with a method.

waveform.append(entry)
self.duration = duration
}
}

extension AudioRecordingInfo {
static let initial = AudioRecordingInfo(waveform: [], duration: 0)
}

extension MessageComposerViewModel: AudioRecordingDelegate {
public func audioRecorder(
_ audioRecorder: AudioRecording,
didUpdateContext: AudioRecordingContext
) {
audioRecordingInfo.update(
with: didUpdateContext.averagePower,
duration: didUpdateContext.duration
)
}

public func audioRecorder(
_ audioRecorder: AudioRecording,
didFinishRecordingAtURL location: URL
) {
if audioRecordingInfo == .initial { return }
audioAnalysisFactory?.waveformVisualisation(
fromAudioURL: location,
for: waveformTargetSamples,
completionHandler: { [weak self] result in
guard let self else { return }
switch result {
case let .success(waveform):
DispatchQueue.main.async {
let recording = AddedVoiceRecording(
url: location,
duration: self.audioRecordingInfo.duration,
waveform: waveform
)
if self.recordingState == .stopped {
self.pendingAudioRecording = recording
self.audioRecordingInfo.waveform = waveform
} else {
self.addedVoiceRecordings.append(recording)
self.recordingState = .initial
self.audioRecordingInfo = .initial
}
}
case let .failure(error):
log.error(error)
self.recordingState = .initial
}
}
)
}

public func audioRecorder(
_ audioRecorder: AudioRecording,
didFailWithError error: Error
) {
log.error(error)
recordingState = .initial
audioRecordingInfo = .initial
}
}

extension MessageComposerViewModel {
public func startRecording() {
utils.audioSessionFeedbackGenerator.feedbackForBeginRecording()
audioRecorder.beginRecording {
log.debug("started recording")
}
}

public func stopRecording() {
utils.audioSessionFeedbackGenerator.feedbackForStopRecording()
audioRecorder.stopRecording()
}

public func resumeRecording() {
utils.audioSessionFeedbackGenerator.feedbackForBeginRecording()
audioRecorder.resumeRecording()
}

public func pauseRecording() {
utils.audioSessionFeedbackGenerator.feedbackForPause()
audioRecorder.pauseRecording()
}
}

extension MessageComposerViewModel {
public func discardRecording() {
recordingState = .initial
audioRecordingInfo = .initial
stopRecording()
}

public func confirmRecording() {
if recordingState == .stopped {
if let pending = pendingAudioRecording {
addedVoiceRecordings.append(pending)
pendingAudioRecording = nil
audioRecordingInfo = .initial
recordingState = .initial
}
} else {
stopRecording()
}
}

public func previewRecording() {
recordingState = .stopped
stopRecording()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import SwiftUI
/// View model for the `MessageComposerView`.
open class MessageComposerViewModel: ObservableObject {
@Injected(\.chatClient) private var chatClient
@Injected(\.utils) private var utils
@Injected(\.utils) internal var utils

@Published public var pickerState: AttachmentPickerState = .photos {
didSet {
Expand Down Expand Up @@ -69,6 +69,12 @@ open class MessageComposerViewModel: ObservableObject {
checkPickerSelectionState()
}
}

@Published public var addedVoiceRecordings = [AddedVoiceRecording]() {
didSet {
checkPickerSelectionState()
}
}

@Published public var addedCustomAttachments = [CustomAttachment]() {
didSet {
Expand Down Expand Up @@ -124,9 +130,40 @@ open class MessageComposerViewModel: ObservableObject {
@Published public var suggestions = [String: Any]()
@Published public var cooldownDuration: Int = 0
@Published public var attachmentSizeExceeded: Bool = false
@Published public var recordingState: RecordingState = .initial {
didSet {
if case let .recording(location) = recordingState {
if location.y < RecordingConstants.lockMaxDistance {
recordingState = .locked
} else if location.x < RecordingConstants.cancelMaxDistance {
audioRecordingInfo = .initial
recordingState = .initial
stopRecording()
}
} else if recordingState == .showingTip {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.recordingState = .initial
}
}
}
}

@Published public var audioRecordingInfo = AudioRecordingInfo.initial

public let channelController: ChatChannelController
public var messageController: ChatMessageController?
public var waveformTargetSamples: Int = 100
public internal(set) var pendingAudioRecording: AddedVoiceRecording?

internal lazy var audioRecorder: AudioRecording = {
let audioRecorder = StreamAudioRecorder()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way for integrators to changes this with their own implementation? (Something similar to what Picslo needed a few days ago)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good point, will add a way 👍

audioRecorder.subscribe(self)
return audioRecorder
}()

internal lazy var audioAnalysisFactory: AudioAnalysisEngine? = try? .init(
assetPropertiesLoader: StreamAssetPropertyLoader()
)

private var timer: Timer?
private var cooldownPeriod = 0
Expand Down Expand Up @@ -222,6 +259,17 @@ open class MessageComposerViewModel: ObservableObject {
_ = url.startAccessingSecurityScopedResource()
return try AnyAttachmentPayload(localFileURL: url, attachmentType: .file)
}
attachments += try addedVoiceRecordings.map { recording in
_ = recording.url.startAccessingSecurityScopedResource()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is recording.url a bookmark? If not, startAccessingSecurityScopedResource probably isn't necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had issues with regular file attachments because we didn't have this. I would prefer to keep it, unless you see some risks?

var localMetadata = AnyAttachmentLocalMetadata()
localMetadata.duration = recording.duration
localMetadata.waveformData = recording.waveform
return try AnyAttachmentPayload(
localFileURL: recording.url,
attachmentType: .voiceRecording,
localMetadata: localMetadata
)
}

attachments += addedCustomAttachments.map { attachment in
attachment.content
Expand Down Expand Up @@ -282,7 +330,8 @@ open class MessageComposerViewModel: ObservableObject {
return !addedAssets.isEmpty ||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
!addedFileURLs.isEmpty ||
!addedCustomAttachments.isEmpty
!addedCustomAttachments.isEmpty ||
!addedVoiceRecordings.isEmpty
}

public var sendInChannelShown: Bool {
Expand Down Expand Up @@ -347,7 +396,17 @@ open class MessageComposerViewModel: ObservableObject {
urls.append(added)
}
}
addedFileURLs = urls
if addedFileURLs.count == urls.count {
var addedRecordings = [AddedVoiceRecording]()
for added in addedVoiceRecordings {
if added.url != url {
addedRecordings.append(added)
}
}
addedVoiceRecordings = addedRecordings
} else {
addedFileURLs = urls
}
} else {
var images = [AddedAsset]()
for image in addedAssets {
Expand Down Expand Up @@ -511,6 +570,7 @@ open class MessageComposerViewModel: ObservableObject {
text = ""
addedAssets = []
addedFileURLs = []
addedVoiceRecordings = []
addedCustomAttachments = []
composerCommand = nil
mentionedUsers = Set<ChatUser>()
Expand Down
Loading
Loading