diff --git a/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageBubbleCell.swift index eaeaf722b..09108d0c9 100644 --- a/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageBubbleCell.swift +++ b/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageBubbleCell.swift @@ -18,7 +18,7 @@ import Foundation class VoiceMessageBubbleCell: SizableBaseBubbleCell, BubbleCellReactionsDisplayable { - private var playbackView: VoiceMessagePlaybackView! + private var playbackController: VoiceMessagePlaybackController! override func render(_ cellData: MXKCellData!) { super.render(cellData) @@ -31,7 +31,7 @@ class VoiceMessageBubbleCell: SizableBaseBubbleCell, BubbleCellReactionsDisplaya fatalError("Invalid attachment type passed to a voice message cell.") } - playbackView.attachment = data.attachment + playbackController.attachment = data.attachment } override func setupViews() { @@ -44,9 +44,9 @@ class VoiceMessageBubbleCell: SizableBaseBubbleCell, BubbleCellReactionsDisplaya return } - playbackView = VoiceMessagePlaybackView.instanceFromNib() - bubbleCellContentView?.addSubview(playbackView) + playbackController = VoiceMessagePlaybackController() + bubbleCellContentView?.addSubview(playbackController.playbackView) - contentView.vc_addSubViewMatchingParent(playbackView) + contentView.vc_addSubViewMatchingParent(playbackController.playbackView) } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioRecorder.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioRecorder.swift index 4bf56db84..e639c6ce6 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioRecorder.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioRecorder.swift @@ -39,6 +39,10 @@ class VoiceMessageAudioRecorder: NSObject, AVAudioRecorderDelegate { return audioRecorder?.currentTime ?? 0 } + var isRecording: Bool { + return audioRecorder?.isRecording ?? false + } + weak var delegate: VoiceMessageAudioRecorderDelegate? func recordWithOuputURL(_ url: URL) { @@ -52,6 +56,7 @@ class VoiceMessageAudioRecorder: NSObject, AVAudioRecorderDelegate { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) audioRecorder = try AVAudioRecorder(url: url, settings: settings) audioRecorder?.delegate = self + audioRecorder?.isMeteringEnabled = true audioRecorder?.record() delegate?.audioRecorderDidStartRecording(self) } catch { @@ -59,11 +64,31 @@ class VoiceMessageAudioRecorder: NSObject, AVAudioRecorderDelegate { } } - + func stopRecording() { audioRecorder?.stop() } + func peakPowerForChannelNumber(_ channelNumber: Int) -> Float { + guard self.isRecording, let audioRecorder = audioRecorder else { + return 0.0 + } + + audioRecorder.updateMeters() + + return self.normalizedPowerLevelFromDecibels(audioRecorder.peakPower(forChannel: channelNumber)) + } + + func averagePowerForChannelNumber(_ channelNumber: Int) -> Float { + guard self.isRecording, let audioRecorder = audioRecorder else { + return 0.0 + } + + audioRecorder.updateMeters() + + return self.normalizedPowerLevelFromDecibels(audioRecorder.averagePower(forChannel: channelNumber)) + } + // MARK: - AVAudioRecorderDelegate func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) { @@ -77,6 +102,14 @@ class VoiceMessageAudioRecorder: NSObject, AVAudioRecorderDelegate { func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { delegate?.audioRecorder(self, didFailWithError: VoiceMessageAudioRecorderError.genericError) } + + private func normalizedPowerLevelFromDecibels(_ decibels: Float) -> Float { + if decibels < -60.0 || decibels == 0.0 { + return 0.0 + } + + return powf((powf(10.0, 0.05 * decibels) - powf(10.0, 0.05 * -60.0)) * (1.0 / (1.0 - powf(10.0, 0.05 * -60.0))), 1.0 / 2.0) + } } extension String: LocalizedError { diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift new file mode 100644 index 000000000..512c17018 --- /dev/null +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift @@ -0,0 +1,198 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import DSWaveformImage + +enum VoiceMessagePlaybackControllerState { + case stopped + case playing + case paused + case error +} + +class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMessagePlaybackViewDelegate { + private let audioPlayer: VoiceMessageAudioPlayer + private let timeFormatter: DateFormatter + private var displayLink: CADisplayLink! + private var samples: [Float] = [] + + private var state: VoiceMessagePlaybackControllerState = .stopped { + didSet { + updateUI() + displayLink.isPaused = (state != .playing) + } + } + + let playbackView: VoiceMessagePlaybackView + + init() { + playbackView = VoiceMessagePlaybackView.instanceFromNib() + audioPlayer = VoiceMessageAudioPlayer() + + timeFormatter = DateFormatter() + timeFormatter.dateFormat = "m:ss" + + audioPlayer.delegate = self + playbackView.delegate = self + + displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLinkTick)) + displayLink.isPaused = true + displayLink.add(to: .current, forMode: .common) + } + + var attachment: MXKAttachment? { + didSet { + if oldValue?.contentURL == attachment?.contentURL && + oldValue?.eventSentState == attachment?.eventSentState { + return + } + + switch attachment?.eventSentState { + case MXEventSentStateFailed: + state = .error + default: + state = .stopped + loadAttachmentData() + } + } + } + + // MARK: - VoiceMessagePlaybackViewDelegate + + func voiceMessagePlaybackViewDidRequestToggle() { + if audioPlayer.isPlaying { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + + // MARK: - VoiceMessageAudioPlayerDelegate + + func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) { + updateUI() + } + + func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { + state = .playing + } + + func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { + state = .paused + } + + func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) { + state = .error + MXLog.error("Failed playing voice message with error: \(error)") + } + + func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { + audioPlayer.seekToTime(0.0) + state = .stopped + } + + // MARK: - Private + + @objc private func handleDisplayLinkTick() { + updateUI() + } + + private func updateUI() { + var details = VoiceMessagePlaybackViewDetails() + + details.playbackEnabled = (state != .error) + details.playing = (state == .playing) + details.samples = samples + + switch state { + case .stopped: + details.currentTime = timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: audioPlayer.duration)) + details.progress = 0.0 + default: + details.currentTime = timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: audioPlayer.currentTime)) + details.progress = (audioPlayer.duration > 0.0 ? audioPlayer.currentTime / audioPlayer.duration : 0.0) + } + + playbackView.configureWithDetails(details) + } + + private func loadAttachmentData() { + guard let attachment = attachment else { + return + } + + if attachment.isEncrypted { + attachment.decrypt(toTempFile: { [weak self] filePath in + self?.loadFileAtPath(filePath) + }, failure: { [weak self] error in + // A nil error in this case is a cancellation on the MXMediaLoader + if let error = error { + MXLog.error("Failed decrypting attachment with error: \(String(describing: error))") + self?.state = .error + } + }) + } else { + attachment.prepare({ [weak self] in + self?.loadFileAtPath(attachment.cacheFilePath) + }, failure: { [weak self] error in + MXLog.error("Failed preparing attachment with error: \(String(describing: error))") + self?.state = .error + }) + } + } + + private func loadFileAtPath(_ path: String?) { + guard let filePath = path else { + return + } + + let url = URL(fileURLWithPath: filePath) + + // AVPlayer doesn't want to play it otherwise. https://stackoverflow.com/a/9350824 + let newURL = url.appendingPathExtension("m4a") + + do { + try? FileManager.default.removeItem(at: newURL) + try FileManager.default.moveItem(at: url, to: newURL) + } catch { + self.state = .error + MXLog.error("Failed appending voice message extension.") + return + } + + audioPlayer.loadContentFromURL(newURL) + + let requiredNumberOfSamples = playbackView.getRequiredNumberOfSamples() + + if requiredNumberOfSamples == 0 { + return + } + + let analyser = WaveformAnalyzer(audioAssetURL: newURL) + analyser?.samples(count: requiredNumberOfSamples, completionHandler: { [weak self] samples in + guard let samples = samples else { + self?.state = .error + return + } + + DispatchQueue.main.async { + self?.samples = samples + self?.updateUI() + } + }) + } +} diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackView.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackView.swift index 941a99d3a..7e9ddce0d 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackView.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackView.swift @@ -15,20 +15,21 @@ // import Foundation -import DSWaveformImage -private enum VoiceMessagePlaybackViewUIState { - case stopped - case playing - case paused - case error +protocol VoiceMessagePlaybackViewDelegate: AnyObject { + func voiceMessagePlaybackViewDidRequestToggle() } -class VoiceMessagePlaybackView: UIView, VoiceMessageAudioPlayerDelegate { +struct VoiceMessagePlaybackViewDetails { + var currentTime: String = "" + var progress = 0.0 + var samples: [Float] = [] + var playing: Bool = false + var playbackEnabled = false +} + +class VoiceMessagePlaybackView: UIView { - private let audioPlayer: VoiceMessageAudioPlayer - private var displayLink: CADisplayLink! - private let timeFormatter: DateFormatter private var waveformView: VoiceMessageWaveformView! @IBOutlet private var backgroundView: UIView! @@ -36,29 +37,9 @@ class VoiceMessagePlaybackView: UIView, VoiceMessageAudioPlayerDelegate { @IBOutlet private var elapsedTimeLabel: UILabel! @IBOutlet private var waveformContainerView: UIView! - private var state: VoiceMessagePlaybackViewUIState = .stopped { - didSet { - updateUI() - displayLink.isPaused = (state != .playing) - } - } + weak var delegate: VoiceMessagePlaybackViewDelegate? - var attachment: MXKAttachment? { - didSet { - if oldValue?.contentURL == attachment?.contentURL && - oldValue?.eventSentState == attachment?.eventSentState { - return - } - - switch attachment?.eventSentState { - case MXEventSentStateFailed: - state = .error - default: - state = .stopped - loadAttachmentData() - } - } - } + var details: VoiceMessagePlaybackViewDetails? static func instanceFromNib() -> VoiceMessagePlaybackView { let nib = UINib(nibName: "VoiceMessagePlaybackView", bundle: nil) @@ -68,172 +49,58 @@ class VoiceMessagePlaybackView: UIView, VoiceMessageAudioPlayerDelegate { return view } - override func didMoveToWindow() { - if self.window == nil { - audioPlayer.stop() - displayLink.invalidate() - } - } - - required init?(coder: NSCoder) { - audioPlayer = VoiceMessageAudioPlayer() - - timeFormatter = DateFormatter() - timeFormatter.dateFormat = "m:ss" - - super.init(coder: coder) - - NotificationCenter.default.addObserver(self, selector: #selector(handleThemeDidChange), name: .themeServiceDidChangeTheme, object: nil) - - audioPlayer.delegate = self - - displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLinkTick)) - displayLink.isPaused = true - displayLink.add(to: .current, forMode: .common) - } - override func awakeFromNib() { super.awakeFromNib() + NotificationCenter.default.addObserver(self, selector: #selector(handleThemeDidChange), name: .themeServiceDidChangeTheme, object: nil) + backgroundView.layer.cornerRadius = 12.0 waveformView = VoiceMessageWaveformView(frame: waveformContainerView.bounds) waveformContainerView.vc_addSubViewMatchingParent(waveformView) + } + + func configureWithDetails(_ details: VoiceMessagePlaybackViewDetails?) { + guard let details = details else { + return + } - updateUI() - } - - // MARK: - VoiceMessageAudioPlayerDelegate - - func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) { - updateUI() - } - - func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { - state = .playing - } - - func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { - state = .paused - } - - func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) { - state = .error - MXLog.error("Failed playing voice message with error: \(error)") - } - - func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { - audioPlayer.seekToTime(0.0) - state = .stopped - } - - // MARK: - Private - - private func updateUI() { - playButton.isEnabled = (state != .error) + playButton.isEnabled = details.playbackEnabled + elapsedTimeLabel.text = details.currentTime + waveformView.progress = details.progress if ThemeService.shared().isCurrentThemeDark() { - playButton.setImage((state == .playing ? Asset.Images.voiceMessagePauseButtonDark.image : Asset.Images.voiceMessagePlayButtonDark.image), for: .normal) + playButton.setImage((details.playing ? Asset.Images.voiceMessagePauseButtonDark.image : Asset.Images.voiceMessagePlayButtonDark.image), for: .normal) backgroundView.backgroundColor = UIColor(rgb: 0x394049) waveformView.primarylineColor = ThemeService.shared().theme.colors.quarterlyContent waveformView.secondaryLineColor = ThemeService.shared().theme.colors.secondaryContent elapsedTimeLabel.textColor = UIColor(rgb: 0x8E99A4) } else { - playButton.setImage((state == .playing ? Asset.Images.voiceMessagePauseButtonLight.image : Asset.Images.voiceMessagePlayButtonLight.image), for: .normal) + playButton.setImage((details.playing ? Asset.Images.voiceMessagePauseButtonLight.image : Asset.Images.voiceMessagePlayButtonLight.image), for: .normal) backgroundView.backgroundColor = UIColor(rgb: 0xE3E8F0) waveformView.primarylineColor = ThemeService.shared().theme.colors.quarterlyContent waveformView.secondaryLineColor = ThemeService.shared().theme.colors.secondaryContent elapsedTimeLabel.textColor = UIColor(rgb: 0x737D8C) } - switch state { - case .stopped: - elapsedTimeLabel.text = timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: audioPlayer.duration)) - waveformView.progress = 0.0 - default: - elapsedTimeLabel.text = timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: audioPlayer.currentTime)) - waveformView.progress = (audioPlayer.duration > 0.0 ? audioPlayer.currentTime / audioPlayer.duration : 0.0) - } + waveformView.setSamples(details.samples) + + self.details = details } - @IBAction private func onPlayButtonTap() { - if audioPlayer.isPlaying { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - - @objc private func handleDisplayLinkTick() { - updateUI() - } - - private func loadAttachmentData() { - guard let attachment = attachment else { - return - } - - if attachment.isEncrypted { - attachment.decrypt(toTempFile: { [weak self] filePath in - self?.loadFileAtPath(filePath) - }, failure: { [weak self] error in - // A nil error in this case is a cancellation on the MXMediaLoader - if let error = error { - MXLog.error("Failed decrypting attachment with error: \(String(describing: error))") - self?.state = .error - } - }) - } else { - attachment.prepare({ [weak self] in - self?.loadFileAtPath(attachment.cacheFilePath) - }, failure: { [weak self] error in - MXLog.error("Failed preparing attachment with error: \(String(describing: error))") - self?.state = .error - }) - } - } - - private func loadFileAtPath(_ path: String?) { - guard let filePath = path else { - return - } - - let url = URL(fileURLWithPath: filePath) - - // AVPlayer doesn't want to play it otherwise. https://stackoverflow.com/a/9350824 - let newURL = url.appendingPathExtension("m4a") - - do { - try FileManager.default.moveItem(at: url, to: newURL) - } catch { - self.state = .error - MXLog.error("Failed appending voice message extension.") - return - } - - audioPlayer.loadContentFromURL(newURL) - + func getRequiredNumberOfSamples() -> Int { waveformView.setNeedsLayout() waveformView.layoutIfNeeded() + return waveformView.requiredNumberOfSamples + } + + // MARK: - Private - if waveformView.requiredNumberOfSamples == 0 { - return - } - - let analyser = WaveformAnalyzer(audioAssetURL: newURL) - analyser?.samples(count: waveformView.requiredNumberOfSamples, completionHandler: { [weak self] samples in - guard let samples = samples else { - self?.state = .error - return - } - - DispatchQueue.main.async { - self?.waveformView.setSamples(samples) - } - }) + @IBAction private func onPlayButtonTap() { + delegate?.voiceMessagePlaybackViewDidRequestToggle() } @objc private func handleThemeDidChange() { - updateUI() + configureWithDetails(details) } }