mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Allow to record a voice message (#1926)
This commit is contained in:
-132
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-56
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-29
@@ -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)
|
||||
|
||||
|
||||
+4
-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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user