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
@@ -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))
}
}