mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Add voice message playback from the timeline (#1844)
This commit is contained in:
+4
-4
@@ -82,10 +82,10 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
||||
|
||||
func retryDecryption(for sessionID: String) async { }
|
||||
|
||||
func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? {
|
||||
VoiceRoomPlaybackViewState(duration: 10.0,
|
||||
waveform: nil,
|
||||
progress: 0.0)
|
||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
|
||||
AudioPlayerState(duration: 10.0,
|
||||
waveform: nil,
|
||||
progress: 0.0)
|
||||
}
|
||||
|
||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async { }
|
||||
|
||||
+3
-1
@@ -19,7 +19,9 @@ import Foundation
|
||||
struct MockRoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
|
||||
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol {
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol {
|
||||
let timelineController = MockRoomTimelineController()
|
||||
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
|
||||
return timelineController
|
||||
|
||||
+70
-19
@@ -23,6 +23,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
private let timelineProvider: RoomTimelineProviderProtocol
|
||||
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||
private let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let serialDispatchQueue: DispatchQueue
|
||||
|
||||
@@ -36,7 +38,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
||||
|
||||
private(set) var timelineItems = [RoomTimelineItemProtocol]()
|
||||
private var timelineAudioPlaybackViewStates = [TimelineItemIdentifier: VoiceRoomPlaybackViewState]()
|
||||
private var timelineAudioPlayerStates = [TimelineItemIdentifier: AudioPlayerState]()
|
||||
|
||||
var roomID: String {
|
||||
roomProxy.id
|
||||
@@ -45,11 +47,15 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
init(roomProxy: RoomProxyProtocol,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
||||
appSettings: AppSettings) {
|
||||
self.roomProxy = roomProxy
|
||||
timelineProvider = roomProxy.timelineProvider
|
||||
self.timelineItemFactory = timelineItemFactory
|
||||
self.mediaProvider = mediaProvider
|
||||
self.mediaPlayerProvider = mediaPlayerProvider
|
||||
self.voiceMessageMediaManager = voiceMessageMediaManager
|
||||
self.appSettings = appSettings
|
||||
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
|
||||
|
||||
@@ -222,32 +228,77 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
await roomProxy.retryDecryption(for: sessionID)
|
||||
}
|
||||
|
||||
func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? {
|
||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
|
||||
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
|
||||
MXLog.error("timelineItem not found")
|
||||
return .none
|
||||
fatalError("TimelineItem \(itemID) not found")
|
||||
}
|
||||
|
||||
switch timelineItem {
|
||||
case let item as VoiceRoomTimelineItem:
|
||||
if let playbackViewState = timelineAudioPlaybackViewStates[itemID] {
|
||||
return playbackViewState
|
||||
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
|
||||
fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
|
||||
}
|
||||
|
||||
if let playerState = timelineAudioPlayerStates[itemID] {
|
||||
return playerState
|
||||
}
|
||||
let playerState = AudioPlayerState(duration: voiceMessageRoomTimelineItem.content.duration,
|
||||
waveform: voiceMessageRoomTimelineItem.content.waveform)
|
||||
timelineAudioPlayerStates[itemID] = playerState
|
||||
return playerState
|
||||
}
|
||||
|
||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async {
|
||||
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
|
||||
fatalError("TimelineItem \(itemID) not found")
|
||||
}
|
||||
|
||||
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
|
||||
fatalError("Invalid TimelineItem type for itemID \(itemID) (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
|
||||
}
|
||||
|
||||
guard let source = voiceMessageRoomTimelineItem.content.source else {
|
||||
MXLog.error("Cannot start voice message playback, source is not defined for itemID \(itemID)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let player = await mediaPlayerProvider.player(for: source) as? AudioPlayerProtocol else {
|
||||
MXLog.error("Cannot play a voice message without an audio player")
|
||||
return
|
||||
}
|
||||
|
||||
let playerState = audioPlayerState(for: itemID)
|
||||
|
||||
guard player.mediaSource == source, player.state != .error else {
|
||||
timelineAudioPlayerStates.forEach { itemID, playerState in
|
||||
if itemID != timelineItem.id {
|
||||
playerState.detachAudioPlayer()
|
||||
}
|
||||
}
|
||||
let playbackViewState = VoiceRoomPlaybackViewState(duration: item.content.duration,
|
||||
waveform: item.content.waveform)
|
||||
timelineAudioPlaybackViewStates[itemID] = playbackViewState
|
||||
return playbackViewState
|
||||
default:
|
||||
return .none
|
||||
playerState.attachAudioPlayer(player)
|
||||
|
||||
// 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)
|
||||
}
|
||||
} catch {
|
||||
MXLog.error("Failed to load voice message: \(error)")
|
||||
playerState.reportError(error)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if player.state == .playing {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async { }
|
||||
|
||||
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
|
||||
Task {
|
||||
timelineAudioPlaybackViewStates[itemID]?.updateState(progress: progress)
|
||||
}
|
||||
await timelineAudioPlayerStates[itemID]?.updateState(progress: progress)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
+5
-1
@@ -19,10 +19,14 @@ import Foundation
|
||||
struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
|
||||
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol {
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol {
|
||||
RoomTimelineController(roomProxy: roomProxy,
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: mediaProvider,
|
||||
mediaPlayerProvider: mediaPlayerProvider,
|
||||
voiceMessageMediaManager: voiceMessageMediaManager,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -20,5 +20,7 @@ import Foundation
|
||||
protocol RoomTimelineControllerFactoryProtocol {
|
||||
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol
|
||||
}
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ protocol RoomTimelineControllerProtocol {
|
||||
|
||||
func retryDecryption(for sessionID: String) async
|
||||
|
||||
func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState?
|
||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState
|
||||
|
||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct VoiceRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
|
||||
struct VoiceMessageRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
|
||||
let id: TimelineItemIdentifier
|
||||
let timestamp: String
|
||||
let isOutgoing: Bool
|
||||
+78
-68
@@ -16,12 +16,17 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceRoomPlaybackView: View {
|
||||
@ObservedObject var playbackViewState: VoiceRoomPlaybackViewState
|
||||
struct VoiceMessageRoomPlaybackView: View {
|
||||
@ObservedObject var playerState: AudioPlayerState
|
||||
let onPlayPause: () -> Void
|
||||
let onSeek: (Double) -> Void
|
||||
let onScrubbing: (Bool) -> Void
|
||||
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
@State private var sendFeedback = false
|
||||
|
||||
@ScaledMetric private var waveformLineWidth = 2.0
|
||||
@ScaledMetric private var waveformLinePadding = 2.0
|
||||
private let waveformMaxWidth: CGFloat = 150
|
||||
private let playPauseButtonSize = CGSize(width: 32, height: 32)
|
||||
|
||||
@@ -30,55 +35,18 @@ struct VoiceRoomPlaybackView: View {
|
||||
dateFormatter.dateFormat = "m:ss"
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
var onPlayPause: () -> Void = { }
|
||||
var onSeek: (Double) -> Void = { _ in }
|
||||
var onScrubbing: (Bool) -> Void = { _ in }
|
||||
|
||||
private enum DragState: Equatable {
|
||||
case inactive
|
||||
case pressing(progress: Double)
|
||||
case dragging(progress: Double)
|
||||
|
||||
var progress: Double {
|
||||
switch self {
|
||||
case .inactive, .pressing:
|
||||
return .zero
|
||||
case .dragging(let progress):
|
||||
return progress
|
||||
}
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .inactive:
|
||||
return false
|
||||
case .pressing, .dragging:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var isDragging: Bool {
|
||||
switch self {
|
||||
case .inactive, .pressing:
|
||||
return false
|
||||
case .dragging:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@GestureState private var dragState = DragState.inactive
|
||||
@State private var tapProgress: Double = .zero
|
||||
|
||||
var timeLabelContent: String {
|
||||
// Display the duration if progress is 0.0
|
||||
let percent = playbackViewState.progress > 0.0 ? playbackViewState.progress : 1.0
|
||||
return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playbackViewState.duration * percent))
|
||||
let percent = playerState.progress > 0.0 ? playerState.progress : 1.0
|
||||
return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playerState.duration * percent))
|
||||
}
|
||||
|
||||
var showWaveformCursor: Bool {
|
||||
playbackViewState.playing || dragState.isDragging
|
||||
playerState.playbackState == .playing || dragState.isDragging
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -90,28 +58,27 @@ struct VoiceRoomPlaybackView: View {
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
GeometryReader { geometry in
|
||||
WaveformView(waveform: playbackViewState.waveform, progress: playbackViewState.progress, showCursor: showWaveformCursor)
|
||||
WaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: playerState.waveform, progress: playerState.progress, showCursor: showWaveformCursor)
|
||||
// Add a gesture to drag the waveform
|
||||
.gesture(SpatialTapGesture()
|
||||
.simultaneously(with: LongPressGesture())
|
||||
.sequenced(before: DragGesture(coordinateSpace: .local))
|
||||
.sequenced(before: DragGesture(minimumDistance: waveformLinePadding, coordinateSpace: .local))
|
||||
.updating($dragState) { value, state, _ in
|
||||
switch value {
|
||||
// (SpatialTap, LongPress) begins.
|
||||
case .first(let spatialLongPress) where spatialLongPress.second == true:
|
||||
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 == true:
|
||||
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)
|
||||
state = .dragging(progress: progress, distance: geometry.size.width)
|
||||
// Dragging ended or the long press cancelled.
|
||||
default:
|
||||
state = .inactive
|
||||
@@ -129,18 +96,21 @@ struct VoiceRoomPlaybackView: View {
|
||||
onScrubbing(true)
|
||||
feedbackGenerator.prepare()
|
||||
sendFeedback = true
|
||||
case .dragging(let progress):
|
||||
case .dragging(let progress, let totalWidth):
|
||||
if sendFeedback {
|
||||
feedbackGenerator.impactOccurred()
|
||||
sendFeedback = false
|
||||
}
|
||||
if abs(progress - playbackViewState.progress) > 0.01 {
|
||||
let minimumProgress = waveformLinePadding / totalWidth
|
||||
let deltaProgress = abs(progress - playerState.progress)
|
||||
let deltaTime = playerState.duration * deltaProgress
|
||||
if deltaProgress == 0 || deltaProgress >= minimumProgress || deltaTime >= 1.0 {
|
||||
onSeek(max(0, min(progress, 1.0)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.leading, 2)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -148,33 +118,73 @@ struct VoiceRoomPlaybackView: View {
|
||||
Button {
|
||||
onPlayPause()
|
||||
} label: {
|
||||
Image(systemName: playbackViewState.playing ? "pause.fill" : "play.fill")
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.background(
|
||||
Circle()
|
||||
.frame(width: playPauseButtonSize.width,
|
||||
height: playPauseButtonSize.height)
|
||||
.foregroundColor(.compound.bgCanvasDefault)
|
||||
)
|
||||
.padding(.trailing, 7)
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(.compound.bgCanvasDefault)
|
||||
if playerState.playbackState == .loading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay)
|
||||
.offset(x: playerState.playbackState == .playing ? 0 : 2)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(playerState.playbackState == .loading)
|
||||
.frame(width: playPauseButtonSize.width,
|
||||
height: playPauseButtonSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DragState: Equatable {
|
||||
case inactive
|
||||
case pressing(progress: Double)
|
||||
case dragging(progress: Double, distance: Double)
|
||||
|
||||
var progress: Double {
|
||||
switch self {
|
||||
case .inactive, .pressing:
|
||||
return .zero
|
||||
case .dragging(let progress, _):
|
||||
return progress
|
||||
}
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .inactive:
|
||||
return false
|
||||
case .pressing, .dragging:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var isDragging: Bool {
|
||||
switch self {
|
||||
case .inactive, .pressing:
|
||||
return false
|
||||
case .dragging:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceRoomPlaybackView_Previews: PreviewProvider, TestablePreview {
|
||||
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 playbackViewState = VoiceRoomPlaybackViewState(duration: 10.0,
|
||||
waveform: waveform,
|
||||
progress: 0.3)
|
||||
static let playerState = AudioPlayerState(duration: 10.0,
|
||||
waveform: waveform,
|
||||
progress: 0.3)
|
||||
|
||||
static var previews: some View {
|
||||
VoiceRoomPlaybackView(playbackViewState: playbackViewState,
|
||||
onPlayPause: { playbackViewState.updateState(playing: !playbackViewState.playing) },
|
||||
onSeek: { playbackViewState.updateState(progress: $0) })
|
||||
VoiceMessageRoomPlaybackView(playerState: playerState,
|
||||
onPlayPause: { },
|
||||
onSeek: { value in Task { await playerState.updateState(progress: value) } },
|
||||
onScrubbing: { _ in })
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceMessageRoomTimelineView: View {
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.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)
|
||||
}
|
||||
}
|
||||
|
||||
private func onPlaybackPlayPause() {
|
||||
context.send(viewAction: .playPauseAudio(itemID: timelineItem.id))
|
||||
}
|
||||
|
||||
private func onPlaybackSeek(_ progress: Double) {
|
||||
context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress))
|
||||
}
|
||||
|
||||
private func onPlaybackScrubbing(_ dragging: Bool) {
|
||||
if dragging {
|
||||
if playerState.playbackState == .playing {
|
||||
resumePlaybackAfterScrubbing = true
|
||||
context.send(viewAction: .playPauseAudio(itemID: timelineItem.id))
|
||||
}
|
||||
context.send(viewAction: .disableLongPress(itemID: timelineItem.id))
|
||||
} else {
|
||||
context.send(viewAction: .enableLongPress(itemID: timelineItem.id))
|
||||
if resumePlaybackAfterScrubbing {
|
||||
context.send(viewAction: .playPauseAudio(itemID: timelineItem.id))
|
||||
resumePlaybackAfterScrubbing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "audio.ogg",
|
||||
duration: 300,
|
||||
waveform: Waveform.mockWaveform,
|
||||
source: nil,
|
||||
contentType: nil))
|
||||
|
||||
static let playerState = AudioPlayerState(duration: 10.0,
|
||||
waveform: Waveform.mockWaveform,
|
||||
progress: 0.4)
|
||||
|
||||
static var previews: some View {
|
||||
body.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Bubble")
|
||||
body
|
||||
.environment(\.timelineStyle, .plain)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Plain")
|
||||
}
|
||||
|
||||
static var body: some View {
|
||||
VoiceMessageRoomTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +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 Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
class VoiceRoomPlaybackViewState: ObservableObject {
|
||||
let duration: Double
|
||||
let waveform: Waveform
|
||||
@Published private(set) var loading: Bool
|
||||
@Published private(set) var playing: Bool
|
||||
@Published private(set) var progress: Double
|
||||
|
||||
init(duration: Double = 0.0, waveform: Waveform? = nil, progress: Double = 0.0) {
|
||||
self.duration = duration
|
||||
self.waveform = waveform ?? Waveform(data: [])
|
||||
self.progress = progress
|
||||
loading = false
|
||||
playing = false
|
||||
}
|
||||
|
||||
func updateState(progress: Double) {
|
||||
self.progress = max(0.0, min(progress, 1.0))
|
||||
}
|
||||
|
||||
func updateState(playing: Bool) {
|
||||
self.playing = playing
|
||||
}
|
||||
}
|
||||
-93
@@ -1,93 +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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceRoomTimelineView: View {
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
let timelineItem: VoiceRoomTimelineItem
|
||||
let playbackViewState: VoiceRoomPlaybackViewState
|
||||
|
||||
init(timelineItem: VoiceRoomTimelineItem, playbackViewState: VoiceRoomPlaybackViewState?) {
|
||||
self.timelineItem = timelineItem
|
||||
if playbackViewState == nil {
|
||||
MXLog.error("[VoiceRoomTimelineView] Voice audio playback state is missing")
|
||||
}
|
||||
self.playbackViewState = playbackViewState ?? VoiceRoomPlaybackViewState()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
VoiceRoomPlaybackView(playbackViewState: playbackViewState,
|
||||
onPlayPause: onPlaybackPlayPause,
|
||||
onSeek: onPlaybackSeek(_:),
|
||||
onScrubbing: onPlaybackScrubbing(_:))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func onPlaybackPlayPause() {
|
||||
context.send(viewAction: .playPauseAudio(itemID: timelineItem.id))
|
||||
}
|
||||
|
||||
private func onPlaybackSeek(_ progress: Double) {
|
||||
context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress))
|
||||
}
|
||||
|
||||
private func onPlaybackScrubbing(_ dragging: Bool) {
|
||||
if dragging {
|
||||
context.send(viewAction: .disableLongPress(itemID: timelineItem.id))
|
||||
} else {
|
||||
context.send(viewAction: .enableLongPress(itemID: timelineItem.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static let voiceRoomTimelineItem = VoiceRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "audio.ogg",
|
||||
duration: 300,
|
||||
waveform: Waveform.mockWaveform,
|
||||
source: nil,
|
||||
contentType: nil))
|
||||
|
||||
static let playbackViewState = VoiceRoomPlaybackViewState(duration: 10.0,
|
||||
waveform: Waveform.mockWaveform,
|
||||
progress: 0.4)
|
||||
|
||||
static var previews: some View {
|
||||
body.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Bubble")
|
||||
body
|
||||
.environment(\.timelineStyle, .plain)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Plain")
|
||||
}
|
||||
|
||||
static var body: some View {
|
||||
VoiceRoomTimelineView(timelineItem: voiceRoomTimelineItem, playbackViewState: playbackViewState)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -40,8 +40,8 @@ extension Waveform {
|
||||
}
|
||||
|
||||
struct WaveformView: View {
|
||||
private let lineWidth: CGFloat = 2
|
||||
private let linePadding: CGFloat = 2
|
||||
var lineWidth: CGFloat = 2
|
||||
var linePadding: CGFloat = 2
|
||||
var waveform: Waveform
|
||||
private let minimumGraphAmplitude: CGFloat = 1
|
||||
var progress: CGFloat = 0.0
|
||||
|
||||
@@ -276,19 +276,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
_ messageContent: AudioMessageContent,
|
||||
_ isOutgoing: Bool,
|
||||
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
|
||||
VoiceRoomTimelineItem(id: eventItemProxy.id,
|
||||
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
|
||||
isOutgoing: isOutgoing,
|
||||
isEditable: eventItemProxy.isEditable,
|
||||
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildAudioTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
|
||||
VoiceMessageRoomTimelineItem(id: eventItemProxy.id,
|
||||
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
|
||||
isOutgoing: isOutgoing,
|
||||
isEditable: eventItemProxy.isEditable,
|
||||
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildAudioTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
|
||||
}
|
||||
|
||||
private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy,
|
||||
|
||||
@@ -75,7 +75,7 @@ struct RoomTimelineItemView: View {
|
||||
case .poll(let item):
|
||||
PollRoomTimelineView(timelineItem: item)
|
||||
case .voice(let item):
|
||||
VoiceRoomTimelineView(timelineItem: item, playbackViewState: context.viewState.audioPlaybackViewStateProvider?(item.id))
|
||||
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(duration: 0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ enum RoomTimelineItemType: Equatable {
|
||||
case group(CollapsibleTimelineItem)
|
||||
case location(LocationRoomTimelineItem)
|
||||
case poll(PollRoomTimelineItem)
|
||||
case voice(VoiceRoomTimelineItem)
|
||||
case voice(VoiceMessageRoomTimelineItem)
|
||||
|
||||
init(item: RoomTimelineItemProtocol) {
|
||||
switch item {
|
||||
@@ -113,7 +113,7 @@ enum RoomTimelineItemType: Equatable {
|
||||
self = .location(item)
|
||||
case let item as PollRoomTimelineItem:
|
||||
self = .poll(item)
|
||||
case let item as VoiceRoomTimelineItem:
|
||||
case let item as VoiceMessageRoomTimelineItem:
|
||||
self = .voice(item)
|
||||
default:
|
||||
fatalError("Unknown timeline item")
|
||||
|
||||
Reference in New Issue
Block a user