Media gallery - support for files and voice messages (#3605)

* Move the voice message views to where they belong
* Add separate struct for each media events timeline view
* Add support for all the different media gallery message types and get the files section working
This commit is contained in:
Stefan Ceriu
2024-12-12 10:02:10 +02:00
committed by GitHub
parent 606eb0a30a
commit 114255c5ec
30 changed files with 374 additions and 112 deletions
@@ -0,0 +1,88 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
import SwiftUI
struct VoiceMessageRoomTimelineView: View {
@Environment(\.timelineContext) private var context
private let timelineItem: VoiceMessageRoomTimelineItem
private let playerState: AudioPlayerState
@State private var resumePlaybackAfterScrubbing = false
init(timelineItem: VoiceMessageRoomTimelineItem, playerState: AudioPlayerState) {
self.timelineItem = timelineItem
self.playerState = playerState
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
VoiceMessageRoomPlaybackView(playerState: playerState,
onPlayPause: onPlaybackPlayPause,
onSeek: { onPlaybackSeek($0) },
onScrubbing: { onPlaybackScrubbing($0) })
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 400)
}
}
private func onPlaybackPlayPause() {
context?.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id)))
}
private func onPlaybackSeek(_ progress: Double) {
context?.send(viewAction: .handleAudioPlayerAction(.seek(itemID: timelineItem.id, progress: progress)))
}
private func onPlaybackScrubbing(_ dragging: Bool) {
if dragging {
if playerState.playbackState == .playing {
resumePlaybackAfterScrubbing = true
context?.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id)))
}
} else {
if resumePlaybackAfterScrubbing {
context?.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id)))
resumePlaybackAfterScrubbing = false
}
}
}
}
struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static let timelineItemIdentifier = TimelineItemIdentifier.randomEvent
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(filename: "audio.ogg",
duration: 300,
waveform: EstimatedWaveform.mockWaveform,
source: nil,
fileSize: nil,
contentType: nil))
static let playerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItemIdentifier),
title: L10n.commonVoiceMessage,
duration: 10.0,
waveform: EstimatedWaveform.mockWaveform,
progress: 0.4)
static var previews: some View {
body.environmentObject(viewModel.context)
.previewDisplayName("Bubble")
}
static var body: some View {
VoiceMessageRoomTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -0,0 +1,114 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import DSWaveformImage
import DSWaveformImageViews
import SwiftUI
struct VoiceMessageRoomPlaybackView: View {
@ObservedObject var playerState: AudioPlayerState
@ScaledMetric private var waveformLineWidth = 2.0
@ScaledMetric private var waveformLinePadding = 2.0
@GestureState var isDragging = false
let onPlayPause: () -> Void
let onSeek: (Double) -> Void
let onScrubbing: (Bool) -> Void
var timeLabelContent: String {
// Display the duration if progress is 0.0
let percent = playerState.progress > 0.0 ? playerState.progress : 1.0
// If the duration is greater or equal 10 minutes, use the long format
let elapsed = Date(timeIntervalSinceReferenceDate: playerState.duration * percent)
if playerState.duration >= 600 {
return DateFormatter.longElapsedTimeFormatter.string(from: elapsed)
} else {
return DateFormatter.elapsedTimeFormatter.string(from: elapsed)
}
}
var body: some View {
HStack(spacing: 8) {
VoiceMessageButton(state: .init(playerState.playerButtonPlaybackState),
size: .medium,
action: onPlayPause)
Text(timeLabelContent)
.lineLimit(1)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textSecondary)
.monospacedDigit()
.fixedSize(horizontal: true, vertical: true)
waveformView
.waveformInteraction(isDragging: $isDragging,
progress: playerState.progress,
showCursor: playerState.showProgressIndicator,
onSeek: onSeek)
}
.padding(.leading, 2)
.padding(.trailing, 8)
.onChange(of: isDragging) { _, newValue in
onScrubbing(newValue)
}
}
@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)) {
estimatedWaveformView
}
.progressMask(progress: playerState.progress)
} else {
estimatedWaveformView
}
}
private var estimatedWaveformView: some View {
EstimatedWaveformView(lineWidth: waveformLineWidth,
linePadding: waveformLinePadding,
waveform: playerState.waveform,
progress: playerState.progress)
}
}
private extension DateFormatter {
static let elapsedTimeFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "m:ss"
return dateFormatter
}()
static let longElapsedTimeFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "mm:ss"
return dateFormatter
}()
}
struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview {
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(id: .timelineItemIdentifier(.randomEvent),
title: L10n.commonVoiceMessage,
duration: 10.0,
waveform: waveform,
progress: 0.3)
static var previews: some View {
VoiceMessageRoomPlaybackView(playerState: playerState,
onPlayPause: { },
onSeek: { value in Task { await playerState.updateState(progress: value) } },
onScrubbing: { _ in })
.fixedSize(horizontal: false, vertical: true)
}
}