mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
Voice message waveform from audio files (#1919)
* Add DSWaveformImage * Add estimated waveform * Add progress mask modifier * Add real waveform * Fix cursor * Fix scale factor * Refine ProgressMaskModifier * Refine waveform logic * Add docs * Add waveform placeholder
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ import UniformTypeIdentifiers
|
||||
struct AudioRoomTimelineItemContent: Hashable {
|
||||
let body: String
|
||||
let duration: TimeInterval
|
||||
let waveform: Waveform?
|
||||
let waveform: EstimatedWaveform?
|
||||
let source: MediaSourceProxy?
|
||||
let contentType: UTType?
|
||||
}
|
||||
|
||||
+17
-32
@@ -16,11 +16,11 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct Waveform: Equatable, Hashable {
|
||||
struct EstimatedWaveform: Equatable, Hashable {
|
||||
let data: [UInt16]
|
||||
}
|
||||
|
||||
extension Waveform {
|
||||
extension EstimatedWaveform {
|
||||
func normalisedData(keepSamplesCount: Int) -> [Float] {
|
||||
guard keepSamplesCount > 0 else {
|
||||
return []
|
||||
@@ -43,45 +43,30 @@ extension Waveform {
|
||||
}
|
||||
}
|
||||
|
||||
extension Waveform {
|
||||
static let mockWaveform = Waveform(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])
|
||||
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 WaveformView: View {
|
||||
struct EstimatedWaveformView: View {
|
||||
var lineWidth: CGFloat = 2
|
||||
var linePadding: CGFloat = 2
|
||||
var waveform: Waveform
|
||||
var waveform: EstimatedWaveform
|
||||
private let minimumGraphAmplitude: CGFloat = 1
|
||||
var progress: CGFloat = 0.0
|
||||
var showCursor = false
|
||||
|
||||
@State private var normalizedWaveformData: [Float] = []
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle().fill(Color.compound.iconQuaternary)
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
Rectangle().fill(Color.compound.iconSecondary)
|
||||
.frame(width: max(0.0, geometry.size.width * progress), height: geometry.size.height)
|
||||
}
|
||||
.preference(key: ViewSizeKey.self, value: geometry.size)
|
||||
.mask(alignment: .leading) {
|
||||
WaveformShape(lineWidth: lineWidth,
|
||||
linePadding: linePadding,
|
||||
waveformData: normalizedWaveformData)
|
||||
.stroke(Color.compound.iconSecondary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
}
|
||||
// Display a cursor
|
||||
.overlay(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 1).fill(Color.compound.iconAccentTertiary)
|
||||
.offset(CGSize(width: progress * geometry.size.width, height: 0.0))
|
||||
.frame(width: lineWidth, height: geometry.size.height)
|
||||
.opacity(showCursor ? 1 : 0)
|
||||
}
|
||||
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)
|
||||
@@ -122,7 +107,7 @@ private struct WaveformShape: Shape {
|
||||
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
|
||||
@@ -137,7 +122,7 @@ struct WaveformView_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 {
|
||||
WaveformView(waveform: Waveform.mockWaveform, progress: 0.5)
|
||||
EstimatedWaveformView(waveform: EstimatedWaveform.mockWaveform, progress: 0.5)
|
||||
.frame(width: 140, height: 50)
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-5
@@ -14,6 +14,8 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import DSWaveformImage
|
||||
import DSWaveformImageViews
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceMessageRoomPlaybackView: View {
|
||||
@@ -74,7 +76,15 @@ struct VoiceMessageRoomPlaybackView: View {
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
WaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: playerState.waveform, progress: playerState.progress, showCursor: showWaveformCursor)
|
||||
waveformView
|
||||
.overlay(alignment: .leading) {
|
||||
// Display a cursor
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(Color.compound.iconAccentTertiary)
|
||||
.offset(CGSize(width: playerState.progress * geometry.size.width, height: 0.0))
|
||||
.frame(width: waveformLineWidth, height: geometry.size.height)
|
||||
.opacity(showWaveformCursor ? 1 : 0)
|
||||
}
|
||||
// Add a gesture to drag the waveform
|
||||
.gesture(SpatialTapGesture()
|
||||
.simultaneously(with: LongPressGesture())
|
||||
@@ -147,6 +157,26 @@ struct VoiceMessageRoomPlaybackView: View {
|
||||
.frame(width: playPauseButtonSize,
|
||||
height: playPauseButtonSize)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var waveformView: some View {
|
||||
if let url = playerState.fileURL {
|
||||
WaveformView(audioURL: url,
|
||||
configuration: .init(style: .striped(.init(color: .black, width: waveformLineWidth, spacing: waveformLinePadding)),
|
||||
verticalScalingFactor: 1.0),
|
||||
placeholder: { estimatedWaveformView })
|
||||
.progressMask(progress: playerState.progress)
|
||||
} else {
|
||||
estimatedWaveformView
|
||||
}
|
||||
}
|
||||
|
||||
private var estimatedWaveformView: some View {
|
||||
EstimatedWaveformView(lineWidth: waveformLineWidth,
|
||||
linePadding: waveformLinePadding,
|
||||
waveform: playerState.waveform,
|
||||
progress: playerState.progress)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DragState: Equatable {
|
||||
@@ -183,10 +213,10 @@ private enum DragState: Equatable {
|
||||
}
|
||||
|
||||
struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview {
|
||||
static let waveform = Waveform(data: [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, 0, 0, 0, 3])
|
||||
static let waveform = EstimatedWaveform(data: [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, 0, 0, 0, 3])
|
||||
|
||||
static var playerState = AudioPlayerState(duration: 10.0,
|
||||
waveform: waveform,
|
||||
|
||||
+2
-2
@@ -75,12 +75,12 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "audio.ogg",
|
||||
duration: 300,
|
||||
waveform: Waveform.mockWaveform,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
source: nil,
|
||||
contentType: nil))
|
||||
|
||||
static let playerState = AudioPlayerState(duration: 10.0,
|
||||
waveform: Waveform.mockWaveform,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
progress: 0.4)
|
||||
|
||||
static var previews: some View {
|
||||
|
||||
Reference in New Issue
Block a user