Allow to record a voice message (#1926)

This commit is contained in:
Nicolas Mauri
2023-10-23 17:47:36 +02:00
committed by GitHub
parent 65b7c1df20
commit 2f57fbc77d
62 changed files with 1805 additions and 342 deletions
@@ -89,7 +89,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func retryDecryption(for sessionID: String) async { }
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
AudioPlayerState(duration: 10.0,
AudioPlayerState(id: .timelineItemIdentifier(itemID),
duration: 10.0,
waveform: nil,
progress: 0.0)
}
@@ -38,7 +38,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
private(set) var timelineItems = [RoomTimelineItemProtocol]()
private var timelineAudioPlayerStates = [TimelineItemIdentifier: AudioPlayerState]()
var roomID: String {
roomProxy.id
@@ -249,13 +248,14 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
}
if let playerState = timelineAudioPlayerStates[itemID] {
if let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) {
return playerState
}
let playerState = AudioPlayerState(duration: voiceMessageRoomTimelineItem.content.duration,
let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID),
duration: voiceMessageRoomTimelineItem.content.duration,
waveform: voiceMessageRoomTimelineItem.content.waveform)
timelineAudioPlayerStates[itemID] = playerState
mediaPlayerProvider.register(audioPlayerState: playerState)
return playerState
}
@@ -273,52 +273,48 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return
}
guard let player = await mediaPlayerProvider.player(for: source) as? AudioPlayerProtocol else {
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
MXLog.error("Cannot play a voice message without an audio player")
return
}
let playerState = audioPlayerState(for: itemID)
let audioPlayerState = audioPlayerState(for: itemID)
guard player.mediaSource == source, player.state != .error else {
timelineAudioPlayerStates.forEach { itemID, playerState in
if itemID != timelineItem.id {
playerState.detachAudioPlayer()
}
}
playerState.attachAudioPlayer(player)
guard audioPlayer.mediaSource == source, audioPlayer.state != .error else {
audioPlayer.stop()
await mediaPlayerProvider.detachAllStates(except: audioPlayerState)
audioPlayerState.attachAudioPlayer(audioPlayer)
// Load content
do {
let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil)
// Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded.
if playerState.isAttached {
player.load(mediaSource: source, using: url)
if audioPlayerState.isAttached {
audioPlayer.load(mediaSource: source, using: url, autoplay: true)
}
} catch {
MXLog.error("Failed to load voice message: \(error)")
playerState.reportError(error)
audioPlayerState.reportError(error)
}
return
}
if player.state == .playing {
player.pause()
if audioPlayer.state == .playing {
audioPlayer.pause()
} else {
player.play()
audioPlayer.play()
}
}
func pauseAudio() {
timelineAudioPlayerStates.forEach { _, playerState in
playerState.detachAudioPlayer()
}
}
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
await timelineAudioPlayerStates[itemID]?.updateState(progress: progress)
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else {
return
}
await playerState.updateState(progress: progress)
}
// MARK: - Private
@@ -402,11 +398,11 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
// Stops the audio player when a voice message is redacted.
if timelineItem is RedactedRoomTimelineItem {
guard let audioState = timelineAudioPlayerStates[timelineItem.id] else {
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
continue
}
audioState.detachAudioPlayer()
timelineAudioPlayerStates.removeValue(forKey: timelineItem.id)
playerState.detachAudioPlayer()
mediaPlayerProvider.unregister(audioPlayerState: playerState)
}
newTimelineItems.append(timelineItem)
@@ -74,9 +74,7 @@ protocol RoomTimelineControllerProtocol {
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState
func playPauseAudio(for itemID: TimelineItemIdentifier) async
func pauseAudio()
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async
}
@@ -1,132 +0,0 @@
//
// Copyright 2023 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 SwiftUI
struct EstimatedWaveform: Equatable, Hashable {
static let dataRange: ClosedRange<UInt16> = 0...1024
let data: [UInt16]
}
extension EstimatedWaveform {
/// Maps the `data` array to Float values in the range 0...1 respecting the `Self.dataRange` limits.
/// Up to `maxSamplesCount` will be returned in the output array
func normalisedData(maxSamplesCount: Int) -> [Float] {
guard maxSamplesCount > 0 else {
return []
}
// Filter the data to keep only the expected number of samples
let result: [UInt16]
if data.count > maxSamplesCount {
result = (0..<maxSamplesCount)
.map { index in
let targetIndex = Int((Double(index) * (Double(data.count) / Double(maxSamplesCount))).rounded())
return UInt16(data[targetIndex])
}
} else {
result = data
}
// Normalize the sample in the allowed range
return result.map { Float($0) / Float(Self.dataRange.upperBound) }
}
}
extension EstimatedWaveform {
static let mockWaveform = EstimatedWaveform(data: [0, 0, 0, 3, 3, 127, 400, 266, 126, 122, 373, 251, 45, 112,
334, 205, 99, 138, 397, 354, 125, 361, 199, 51,
294, 131, 19, 2, 3, 3, 1, 2, 0, 0,
0, 0])
}
struct EstimatedWaveformView: View {
var lineWidth: CGFloat = 2
var linePadding: CGFloat = 2
var waveform: EstimatedWaveform
private let minimumGraphAmplitude: CGFloat = 1
var progress: CGFloat = 0.0
@State private var normalizedWaveformData: [Float] = []
var body: some View {
GeometryReader { geometry in
WaveformShape(lineWidth: lineWidth,
linePadding: linePadding,
waveformData: normalizedWaveformData)
.stroke(Color.compound.iconSecondary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.progressMask(progress: progress)
.preference(key: ViewSizeKey.self, value: geometry.size)
}
.onPreferenceChange(ViewSizeKey.self) { size in
buildNormalizedWaveformData(size: size)
}
}
private func buildNormalizedWaveformData(size: CGSize) {
let count = Int(size.width / (lineWidth + linePadding))
// Rebuild the normalized waveform data only if the count has changed
if normalizedWaveformData.count == count {
return
}
normalizedWaveformData = waveform.normalisedData(maxSamplesCount: count)
}
}
private struct ViewSizeKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
private struct WaveformShape: Shape {
let lineWidth: CGFloat
let linePadding: CGFloat
let waveformData: [Float]
var minimumGraphAmplitude: CGFloat = 1.0
func path(in rect: CGRect) -> Path {
let width = rect.size.width
let height = rect.size.height
let centerY = rect.size.height / 2
var xOffset: CGFloat = lineWidth / 2
var index = 0
var path = Path()
while xOffset <= width {
let sample = CGFloat(index >= waveformData.count ? 0 : waveformData[index])
let drawingAmplitude = max(minimumGraphAmplitude, sample * (height - 2))
path.move(to: CGPoint(x: xOffset, y: centerY - drawingAmplitude / 2))
path.addLine(to: CGPoint(x: xOffset, y: centerY + drawingAmplitude / 2))
xOffset += lineWidth + linePadding
index += 1
}
return path
}
}
struct EstimatedWaveformView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
// Wrap the WaveformView in a VStack otherwise the preview test will fail (because of Prefire / GeometryReader)
VStack {
EstimatedWaveformView(waveform: EstimatedWaveform.mockWaveform, progress: 0.5)
.frame(width: 140, height: 50)
}
}
}
@@ -1,56 +0,0 @@
//
// Copyright 2023 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 SwiftUI
extension View {
func progressMask(progress: CGFloat,
trackColor: Color = .compound.iconSecondary,
backgroundTrackColor: Color = .compound.iconQuaternary) -> some View {
modifier(ProgressMaskModifier(progress: progress,
trackColor: trackColor,
backgroundTrackColor: backgroundTrackColor))
}
}
private struct ProgressMaskModifier: ViewModifier {
private let progress: CGFloat
private let trackColor: Color
private let backgroundTrackColor: Color
init(progress: CGFloat, trackColor: Color, backgroundTrackColor: Color) {
self.progress = progress
self.trackColor = trackColor
self.backgroundTrackColor = backgroundTrackColor
}
func body(content: Content) -> some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(backgroundTrackColor)
.frame(width: geometry.size.width, height: geometry.size.height)
Rectangle()
.fill(trackColor)
.frame(width: max(0.0, geometry.size.width * progress), height: geometry.size.height)
}
.mask {
content
}
}
}
}
@@ -45,8 +45,7 @@ struct VoiceMessageRoomPlaybackView: View {
return dateFormatter
}()
@GestureState private var dragState = DragState.inactive
@State private var tapProgress: Double = .zero
@State var dragState: WaveformViewDragState = .inactive
var timeLabelContent: String {
// Display the duration if progress is 0.0
@@ -85,38 +84,14 @@ struct VoiceMessageRoomPlaybackView: View {
.frame(width: waveformLineWidth, height: geometry.size.height)
.opacity(showWaveformCursor ? 1 : 0)
}
// Add a gesture to drag the waveform
.gesture(SpatialTapGesture()
.simultaneously(with: LongPressGesture())
.sequenced(before: DragGesture(minimumDistance: waveformLinePadding, coordinateSpace: .local))
.updating($dragState) { value, state, _ in
switch value {
// (SpatialTap, LongPress) begins.
case .first(let spatialLongPress) where spatialLongPress.second ?? false:
// Compute the progress with the spatialTap location
let progress = (spatialLongPress.first?.location ?? .zero).x / geometry.size.width
state = .pressing(progress: progress)
// Long press confirmed, dragging may begin.
case .second(let spatialLongPress, let drag) where spatialLongPress.second ?? false:
var progress: Double = tapProgress
// Compute the progress with drag location
if let loc = drag?.location {
progress = loc.x / geometry.size.width
}
state = .dragging(progress: progress)
// Dragging ended or the long press cancelled.
default:
state = .inactive
}
})
.waveformDragGesture($dragState)
}
}
.onChange(of: dragState) { newDragState in
switch newDragState {
case .inactive:
onScrubbing(false)
case .pressing(let progress):
tapProgress = progress
case .pressing:
onScrubbing(true)
feedbackGenerator.prepare()
sendFeedback = true
@@ -218,7 +193,8 @@ struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview {
294, 131, 19, 2, 3, 3, 1, 2, 0, 0,
0, 0, 0, 0, 0, 3])
static var playerState = AudioPlayerState(duration: 10.0,
static var playerState = AudioPlayerState(id: .timelineItemIdentifier(.random),
duration: 10.0,
waveform: waveform,
progress: 0.3)
@@ -65,8 +65,8 @@ struct VoiceMessageRoomTimelineView: View {
struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: .random,
static let timelineItemIdentifier = TimelineItemIdentifier.random
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
@@ -79,7 +79,8 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
source: nil,
contentType: nil))
static let playerState = AudioPlayerState(duration: 10.0,
static let playerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItemIdentifier),
duration: 10.0,
waveform: EstimatedWaveform.mockWaveform,
progress: 0.4)
@@ -75,7 +75,7 @@ struct RoomTimelineItemView: View {
case .poll(let item):
PollRoomTimelineView(timelineItem: item)
case .voice(let item):
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(duration: 0))
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(id: .timelineItemIdentifier(item.id), duration: 0))
}
}