mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-11 13:59:13 +00:00
Fix playback and recording of voice messages (#1948)
This commit is contained in:
parent
37f1a79a11
commit
f9ad44f96a
@ -447,11 +447,11 @@ class AudioRecorderMock: AudioRecorderProtocol {
|
|||||||
var deleteRecordingCalled: Bool {
|
var deleteRecordingCalled: Bool {
|
||||||
return deleteRecordingCallsCount > 0
|
return deleteRecordingCallsCount > 0
|
||||||
}
|
}
|
||||||
var deleteRecordingClosure: (() -> Void)?
|
var deleteRecordingClosure: (() async -> Void)?
|
||||||
|
|
||||||
func deleteRecording() {
|
func deleteRecording() async {
|
||||||
deleteRecordingCallsCount += 1
|
deleteRecordingCallsCount += 1
|
||||||
deleteRecordingClosure?()
|
await deleteRecordingClosure?()
|
||||||
}
|
}
|
||||||
//MARK: - averagePowerForChannelNumber
|
//MARK: - averagePowerForChannelNumber
|
||||||
|
|
||||||
|
@ -243,7 +243,7 @@ struct ComposerToolbar: View {
|
|||||||
} stopRecording: {
|
} stopRecording: {
|
||||||
if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration {
|
if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration {
|
||||||
context.send(viewAction: .cancelVoiceMessageRecording)
|
context.send(viewAction: .cancelVoiceMessageRecording)
|
||||||
withAnimation {
|
withElementAnimation {
|
||||||
showVoiceMessageRecordingTooltip = true
|
showVoiceMessageRecordingTooltip = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -273,7 +273,7 @@ struct ComposerToolbar: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) {
|
||||||
withAnimation {
|
withElementAnimation {
|
||||||
showVoiceMessageRecordingTooltip = false
|
showVoiceMessageRecordingTooltip = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -42,42 +42,38 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
|
|||||||
audioRecorder?.isRecording ?? false
|
audioRecorder?.isRecording ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let dispatchQueue = DispatchQueue(label: "io.element.elementx.audio_recorder", qos: .userInitiated)
|
||||||
|
private var stopped = false
|
||||||
|
|
||||||
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
|
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
|
||||||
|
stopped = false
|
||||||
guard await requestRecordPermission() else {
|
guard await requestRecordPermission() else {
|
||||||
return .failure(.recordPermissionNotGranted)
|
return .failure(.recordPermissionNotGranted)
|
||||||
}
|
}
|
||||||
|
let result = await startRecording(with: recordID)
|
||||||
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
switch result {
|
||||||
AVSampleRateKey: 48000,
|
case .success:
|
||||||
AVEncoderBitRateKey: 128_000,
|
|
||||||
AVNumberOfChannelsKey: 1,
|
|
||||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
|
|
||||||
|
|
||||||
do {
|
|
||||||
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
|
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
|
||||||
let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a")
|
|
||||||
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
|
|
||||||
audioRecorder?.delegate = self
|
|
||||||
audioRecorder?.isMeteringEnabled = true
|
|
||||||
audioRecorder?.record()
|
|
||||||
actionsSubject.send(.didStartRecording)
|
actionsSubject.send(.didStartRecording)
|
||||||
} catch {
|
case .failure(let error):
|
||||||
MXLog.error("audio recording failed: \(error)")
|
|
||||||
actionsSubject.send(.didFailWithError(error: error))
|
actionsSubject.send(.didFailWithError(error: error))
|
||||||
}
|
}
|
||||||
return .success(())
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() async {
|
func stopRecording() async {
|
||||||
guard let audioRecorder, audioRecorder.isRecording else {
|
await withCheckedContinuation { continuation in
|
||||||
return
|
stopRecording {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
audioRecorder.stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteRecording() {
|
func deleteRecording() async {
|
||||||
audioRecorder?.deleteRecording()
|
await withCheckedContinuation { continuation in
|
||||||
|
deleteRecording {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func peakPowerForChannelNumber(_ channelNumber: Int) -> Float {
|
func peakPowerForChannelNumber(_ channelNumber: Int) -> Float {
|
||||||
@ -122,6 +118,68 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func startRecording(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
startRecording(with: recordID) { result in
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRecording(with recordID: AudioRecordingIdentifier, completion: @escaping (Result<Void, AudioRecorderError>) -> Void) {
|
||||||
|
dispatchQueue.async { [weak self] in
|
||||||
|
guard let self, !self.stopped else {
|
||||||
|
completion(.failure(.recordingCancelled))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||||
|
AVSampleRateKey: 48000,
|
||||||
|
AVEncoderBitRateKey: 128_000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
|
||||||
|
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a")
|
||||||
|
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
|
||||||
|
audioRecorder?.delegate = self
|
||||||
|
audioRecorder?.isMeteringEnabled = true
|
||||||
|
audioRecorder?.record()
|
||||||
|
completion(.success(()))
|
||||||
|
} catch {
|
||||||
|
MXLog.error("audio recording failed: \(error)")
|
||||||
|
completion(.failure(.genericError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopRecording(completion: @escaping () -> Void) {
|
||||||
|
dispatchQueue.async { [weak self] in
|
||||||
|
defer {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
guard let self else { return }
|
||||||
|
stopped = true
|
||||||
|
guard let audioRecorder, audioRecorder.isRecording else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audioRecorder.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteRecording(completion: @escaping () -> Void) {
|
||||||
|
dispatchQueue.async { [weak self] in
|
||||||
|
defer {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
guard let self else { return }
|
||||||
|
audioRecorder?.deleteRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - AVAudioRecorderDelegate
|
// MARK: - AVAudioRecorderDelegate
|
||||||
|
|
||||||
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
|
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
|
||||||
|
@ -33,6 +33,7 @@ extension AudioRecordingIdentifier {
|
|||||||
enum AudioRecorderError: Error {
|
enum AudioRecorderError: Error {
|
||||||
case genericError
|
case genericError
|
||||||
case recordPermissionNotGranted
|
case recordPermissionNotGranted
|
||||||
|
case recordingCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AudioRecorderAction {
|
enum AudioRecorderAction {
|
||||||
@ -49,7 +50,7 @@ protocol AudioRecorderProtocol: AnyObject {
|
|||||||
|
|
||||||
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError>
|
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError>
|
||||||
func stopRecording() async
|
func stopRecording() async
|
||||||
func deleteRecording()
|
func deleteRecording() async
|
||||||
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float
|
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +48,6 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
|||||||
return audioPlayerStates[audioPlayerStateID]
|
return audioPlayerStates[audioPlayerStateID]
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func register(audioPlayerState: AudioPlayerState) {
|
func register(audioPlayerState: AudioPlayerState) {
|
||||||
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
|
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
|
||||||
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
|
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
|
||||||
@ -57,7 +56,6 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
|||||||
audioPlayerStates[audioPlayerStateID] = audioPlayerState
|
audioPlayerStates[audioPlayerStateID] = audioPlayerState
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func unregister(audioPlayerState: AudioPlayerState) {
|
func unregister(audioPlayerState: AudioPlayerState) {
|
||||||
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
|
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
|
||||||
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
|
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
|
||||||
@ -66,12 +64,12 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
|||||||
audioPlayerStates[audioPlayerStateID] = nil
|
audioPlayerStates[audioPlayerStateID] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func detachAllStates(except exception: AudioPlayerState?) async {
|
func detachAllStates(except exception: AudioPlayerState?) {
|
||||||
for key in audioPlayerStates.keys {
|
for key in audioPlayerStates.keys {
|
||||||
if let exception, key == audioPlayerStateID(for: exception.id) {
|
if let exception, key == audioPlayerStateID(for: exception.id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
await audioPlayerStates[key]?.detachAudioPlayer()
|
audioPlayerStates[key]?.detachAudioPlayer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ enum MediaPlayerProviderError: Error {
|
|||||||
case unsupportedMediaType
|
case unsupportedMediaType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol MediaPlayerProviderProtocol {
|
protocol MediaPlayerProviderProtocol {
|
||||||
func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError>
|
func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError>
|
||||||
|
|
||||||
|
@ -309,6 +309,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
if audioPlayer.state == .playing {
|
if audioPlayer.state == .playing {
|
||||||
audioPlayer.pause()
|
audioPlayer.pause()
|
||||||
} else {
|
} else {
|
||||||
|
audioPlayerState.attachAudioPlayer(audioPlayer)
|
||||||
audioPlayer.play()
|
audioPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -68,14 +68,14 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
|||||||
|
|
||||||
func cancelRecording() async {
|
func cancelRecording() async {
|
||||||
await audioRecorder.stopRecording()
|
await audioRecorder.stopRecording()
|
||||||
audioRecorder.deleteRecording()
|
await audioRecorder.deleteRecording()
|
||||||
recordingURL = nil
|
recordingURL = nil
|
||||||
previewAudioPlayerState = nil
|
previewAudioPlayerState = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteRecording() async {
|
func deleteRecording() async {
|
||||||
await stopPlayback()
|
await stopPlayback()
|
||||||
audioRecorder.deleteRecording()
|
await audioRecorder.deleteRecording()
|
||||||
previewAudioPlayerState = nil
|
previewAudioPlayerState = nil
|
||||||
recordingURL = nil
|
recordingURL = nil
|
||||||
}
|
}
|
||||||
@ -189,7 +189,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
|||||||
|
|
||||||
// Build the preview audio player
|
// Build the preview audio player
|
||||||
let mediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
|
let mediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
|
||||||
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: mediaSource), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
|
guard case .success(let mediaPlayer) = await mediaPlayerProvider.player(for: mediaSource), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
|
||||||
return .failure(.previewNotAvailable)
|
return .failure(.previewNotAvailable)
|
||||||
}
|
}
|
||||||
previewAudioPlayer = audioPlayer
|
previewAudioPlayer = audioPlayer
|
||||||
|
@ -19,6 +19,7 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class MediaPlayerProviderTests: XCTestCase {
|
class MediaPlayerProviderTests: XCTestCase {
|
||||||
private var mediaPlayerProvider: MediaPlayerProvider!
|
private var mediaPlayerProvider: MediaPlayerProvider!
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ class MediaPlayerProviderTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mediaSourceVideo = MediaSourceProxy(url: someURL, mimeType: "video/mp4")
|
let mediaSourceVideo = MediaSourceProxy(url: someURL, mimeType: "video/mp4")
|
||||||
switch mediaPlayerProvider.player(for: mediaSourceWithoutMimeType) {
|
switch mediaPlayerProvider.player(for: mediaSourceVideo) {
|
||||||
case .failure(.unsupportedMediaType):
|
case .failure(.unsupportedMediaType):
|
||||||
// Ok
|
// Ok
|
||||||
break
|
break
|
||||||
@ -72,11 +73,11 @@ class MediaPlayerProviderTests: XCTestCase {
|
|||||||
// By default, there should be no player state
|
// By default, there should be no player state
|
||||||
XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId))
|
XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId))
|
||||||
|
|
||||||
let audioPlayerState = await AudioPlayerState(id: audioPlayerStateId, duration: 10.0)
|
let audioPlayerState = AudioPlayerState(id: audioPlayerStateId, duration: 10.0)
|
||||||
await mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
||||||
XCTAssertEqual(audioPlayerState, mediaPlayerProvider.playerState(for: audioPlayerStateId))
|
XCTAssertEqual(audioPlayerState, mediaPlayerProvider.playerState(for: audioPlayerStateId))
|
||||||
|
|
||||||
await mediaPlayerProvider.unregister(audioPlayerState: audioPlayerState)
|
mediaPlayerProvider.unregister(audioPlayerState: audioPlayerState)
|
||||||
XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId))
|
XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,17 +85,17 @@ class MediaPlayerProviderTests: XCTestCase {
|
|||||||
let audioPlayer = AudioPlayerMock()
|
let audioPlayer = AudioPlayerMock()
|
||||||
audioPlayer.actions = PassthroughSubject<AudioPlayerAction, Never>().eraseToAnyPublisher()
|
audioPlayer.actions = PassthroughSubject<AudioPlayerAction, Never>().eraseToAnyPublisher()
|
||||||
|
|
||||||
let audioPlayerStates = await Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10)
|
let audioPlayerStates = Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10)
|
||||||
for audioPlayerState in audioPlayerStates {
|
for audioPlayerState in audioPlayerStates {
|
||||||
await mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
||||||
await audioPlayerState.attachAudioPlayer(audioPlayer)
|
audioPlayerState.attachAudioPlayer(audioPlayer)
|
||||||
let isAttached = await audioPlayerState.isAttached
|
let isAttached = audioPlayerState.isAttached
|
||||||
XCTAssertTrue(isAttached)
|
XCTAssertTrue(isAttached)
|
||||||
}
|
}
|
||||||
|
|
||||||
await mediaPlayerProvider.detachAllStates(except: nil)
|
mediaPlayerProvider.detachAllStates(except: nil)
|
||||||
for audioPlayerState in audioPlayerStates {
|
for audioPlayerState in audioPlayerStates {
|
||||||
let isAttached = await audioPlayerState.isAttached
|
let isAttached = audioPlayerState.isAttached
|
||||||
XCTAssertFalse(isAttached)
|
XCTAssertFalse(isAttached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,18 +104,18 @@ class MediaPlayerProviderTests: XCTestCase {
|
|||||||
let audioPlayer = AudioPlayerMock()
|
let audioPlayer = AudioPlayerMock()
|
||||||
audioPlayer.actions = PassthroughSubject<AudioPlayerAction, Never>().eraseToAnyPublisher()
|
audioPlayer.actions = PassthroughSubject<AudioPlayerAction, Never>().eraseToAnyPublisher()
|
||||||
|
|
||||||
let audioPlayerStates = await Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10)
|
let audioPlayerStates = Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10)
|
||||||
for audioPlayerState in audioPlayerStates {
|
for audioPlayerState in audioPlayerStates {
|
||||||
await mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
||||||
await audioPlayerState.attachAudioPlayer(audioPlayer)
|
audioPlayerState.attachAudioPlayer(audioPlayer)
|
||||||
let isAttached = await audioPlayerState.isAttached
|
let isAttached = audioPlayerState.isAttached
|
||||||
XCTAssertTrue(isAttached)
|
XCTAssertTrue(isAttached)
|
||||||
}
|
}
|
||||||
|
|
||||||
let exception = audioPlayerStates[1]
|
let exception = audioPlayerStates[1]
|
||||||
await mediaPlayerProvider.detachAllStates(except: exception)
|
mediaPlayerProvider.detachAllStates(except: exception)
|
||||||
for audioPlayerState in audioPlayerStates {
|
for audioPlayerState in audioPlayerStates {
|
||||||
let isAttached = await audioPlayerState.isAttached
|
let isAttached = audioPlayerState.isAttached
|
||||||
if audioPlayerState == exception {
|
if audioPlayerState == exception {
|
||||||
XCTAssertTrue(isAttached)
|
XCTAssertTrue(isAttached)
|
||||||
} else {
|
} else {
|
||||||
|
BIN
UnitTests/__Snapshots__/PreviewTests/test_encryptedHistoryRoomTimelineView.1.png
(Stored with Git LFS)
BIN
UnitTests/__Snapshots__/PreviewTests/test_encryptedHistoryRoomTimelineView.1.png
(Stored with Git LFS)
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user