mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
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:
+2
@@ -24,6 +24,8 @@ struct MediaEventsTimelineScreenViewState: BindableState {
|
||||
var isBackPaginating = false
|
||||
var groups = [MediaEventsTimelineGroup]()
|
||||
|
||||
var activeTimelineContextProvider: (() -> TimelineViewModel.Context)!
|
||||
|
||||
var bindings: MediaEventsTimelineScreenViewStateBindings
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -42,6 +42,12 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
||||
|
||||
super.init(initialViewState: .init(bindings: .init(screenMode: screenMode)), mediaProvider: mediaProvider)
|
||||
|
||||
state.activeTimelineContextProvider = { [weak self] in
|
||||
guard let self else { fatalError() }
|
||||
|
||||
return activeTimelineViewModel.context
|
||||
}
|
||||
|
||||
mediaTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in
|
||||
guard let self, state.bindings.screenMode == .media else {
|
||||
return
|
||||
|
||||
+75
-80
@@ -12,7 +12,7 @@ struct MediaEventsTimelineScreen: View {
|
||||
@ObservedObject var context: MediaEventsTimelineScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
mainContent
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(.compound.bgCanvasDefault)
|
||||
// Doesn't play well with the transformed scrollView
|
||||
@@ -31,6 +31,8 @@ struct MediaEventsTimelineScreen: View {
|
||||
}
|
||||
}
|
||||
.timelineMediaQuickLook(viewModel: $context.mediaPreviewViewModel)
|
||||
.environmentObject(context.viewState.activeTimelineContextProvider())
|
||||
.environment(\.timelineContext, context.viewState.activeTimelineContextProvider())
|
||||
}
|
||||
|
||||
// The scale effects do the following:
|
||||
@@ -39,32 +41,16 @@ struct MediaEventsTimelineScreen: View {
|
||||
// * flip the grid vertically to counteract the scroll view
|
||||
// but also horizontally to preserve the corect item order
|
||||
// * flip the items on both axes have them render correctly
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
private var mainContent: some View {
|
||||
ScrollView {
|
||||
Group {
|
||||
let columns = [GridItem(.adaptive(minimum: 80, maximum: 150), spacing: 1)]
|
||||
LazyVGrid(columns: columns, alignment: .center, spacing: 1) {
|
||||
ForEach(context.viewState.groups) { group in
|
||||
Section(footer: sectionFooterForGroup(group)) {
|
||||
ForEach(group.items) { item in
|
||||
Button {
|
||||
context.send(viewAction: .tappedItem(item))
|
||||
} label: {
|
||||
Color.clear // Let the image aspect fill in place
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.overlay {
|
||||
viewForTimelineItem(item)
|
||||
}
|
||||
.clipped()
|
||||
.scaleEffect(.init(width: -1, height: -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch context.viewState.bindings.screenMode {
|
||||
case .media:
|
||||
mediaContent
|
||||
case .files:
|
||||
filesContent
|
||||
}
|
||||
.scaleEffect(.init(width: -1, height: 1))
|
||||
|
||||
|
||||
header
|
||||
}
|
||||
}
|
||||
@@ -74,6 +60,61 @@ struct MediaEventsTimelineScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaContent: some View {
|
||||
let columns = [GridItem(.adaptive(minimum: 80, maximum: 150), spacing: 1)]
|
||||
LazyVGrid(columns: columns, alignment: .center, spacing: 1) {
|
||||
ForEach(context.viewState.groups) { group in
|
||||
Section {
|
||||
ForEach(group.items) { item in
|
||||
Button {
|
||||
context.send(viewAction: .tappedItem(item))
|
||||
} label: {
|
||||
Color.clear // Let the image aspect fill in place
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.overlay {
|
||||
viewForTimelineItem(item)
|
||||
}
|
||||
.clipped()
|
||||
.scaleEffect(.init(width: -1, height: -1))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
// Use a footer as the header because the scrollView is flipped
|
||||
SeparatorMediaEventsTimelineView(group: group)
|
||||
.scaleEffect(.init(width: -1, height: -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(.init(width: -1, height: 1))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var filesContent: some View {
|
||||
LazyVStack(alignment: .center, spacing: 16) {
|
||||
ForEach(context.viewState.groups) { group in
|
||||
Section {
|
||||
ForEach(group.items) { item in
|
||||
viewForTimelineItem(item)
|
||||
.scaleEffect(.init(width: 1, height: -1))
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .tappedItem(item))
|
||||
}
|
||||
.accessibilityActions {
|
||||
Button(L10n.actionShow) {
|
||||
context.send(viewAction: .tappedItem(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
// Use a footer as the header because the scrollView is flipped
|
||||
SeparatorMediaEventsTimelineView(group: group)
|
||||
.scaleEffect(.init(width: 1, height: -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
// Needs to be wrapped in a LazyStack otherwise appearance calls don't trigger
|
||||
LazyVStack(spacing: 0) {
|
||||
@@ -93,71 +134,25 @@ struct MediaEventsTimelineScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func sectionFooterForGroup(_ group: MediaEventsTimelineGroup) -> some View {
|
||||
Text(group.title)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.frame(alignment: .center)
|
||||
.scaleEffect(.init(width: -1, height: -1))
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func viewForTimelineItem(_ item: RoomTimelineItemViewState) -> some View {
|
||||
switch item.type {
|
||||
case .image(let timelineItem):
|
||||
#warning("Make this work for gifs")
|
||||
LoadableImage(mediaSource: timelineItem.content.thumbnailInfo?.source ?? timelineItem.content.imageInfo.source,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
size: timelineItem.content.thumbnailInfo?.size ?? timelineItem.content.imageInfo.size,
|
||||
mediaProvider: context.mediaProvider) {
|
||||
placeholder
|
||||
}
|
||||
.mediaItemAspectRatio(imageInfo: timelineItem.content.thumbnailInfo ?? timelineItem.content.imageInfo)
|
||||
ImageMediaEventsTimelineView(timelineItem: timelineItem)
|
||||
case .video(let timelineItem):
|
||||
if let thumbnailSource = timelineItem.content.thumbnailInfo?.source {
|
||||
LoadableImage(mediaSource: thumbnailSource,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
size: timelineItem.content.thumbnailInfo?.size,
|
||||
mediaProvider: context.mediaProvider) { imageView in
|
||||
imageView
|
||||
.overlay { playIcon }
|
||||
} placeholder: {
|
||||
placeholder
|
||||
}
|
||||
.mediaItemAspectRatio(imageInfo: timelineItem.content.thumbnailInfo)
|
||||
} else {
|
||||
playIcon
|
||||
}
|
||||
VideoMediaEventsTimelineView(timelineItem: timelineItem)
|
||||
case .file(let timelineItem):
|
||||
FileRoomTimelineView(timelineItem: timelineItem)
|
||||
case .audio(let timelineItem):
|
||||
AudioRoomTimelineView(timelineItem: timelineItem)
|
||||
case .voice(let timelineItem):
|
||||
let defaultPlayerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItem.id), title: L10n.commonVoiceMessage, duration: 0)
|
||||
let playerState = context.viewState.activeTimelineContextProvider().viewState.audioPlayerStateProvider?(timelineItem.id) ?? defaultPlayerState
|
||||
VoiceMessageRoomTimelineView(timelineItem: timelineItem, playerState: playerState)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var playIcon: some View {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
Rectangle()
|
||||
.foregroundColor(.compound._bgBubbleIncoming)
|
||||
.opacity(0.3)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Constrains the max height of a media item in the timeline, whilst preserving its aspect ratio.
|
||||
@ViewBuilder
|
||||
func mediaItemAspectRatio(imageInfo: ImageInfoProxy?) -> some View {
|
||||
aspectRatio(imageInfo?.aspectRatio, contentMode: .fill)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct ImageMediaEventsTimelineView: View {
|
||||
@Environment(\.timelineContext) private var context
|
||||
let timelineItem: ImageRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
loadableImage
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(L10n.commonImage)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadableImage: some View {
|
||||
if timelineItem.content.contentType == .gif {
|
||||
LoadableImage(mediaSource: timelineItem.content.imageInfo.source,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
size: timelineItem.content.imageInfo.size,
|
||||
mediaProvider: context?.mediaProvider) {
|
||||
placeholder
|
||||
}
|
||||
.mediaGalleryTimelineAspectRatio(imageInfo: timelineItem.content.imageInfo)
|
||||
} else {
|
||||
LoadableImage(mediaSource: timelineItem.content.thumbnailInfo?.source ?? timelineItem.content.imageInfo.source,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
size: timelineItem.content.thumbnailInfo?.size ?? timelineItem.content.imageInfo.size,
|
||||
mediaProvider: context?.mediaProvider) {
|
||||
placeholder
|
||||
}
|
||||
.mediaGalleryTimelineAspectRatio(imageInfo: timelineItem.content.thumbnailInfo ?? timelineItem.content.imageInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
Rectangle()
|
||||
.foregroundColor(.compound.bgSubtleSecondary)
|
||||
.opacity(0.3)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func mediaGalleryTimelineAspectRatio(imageInfo: ImageInfoProxy?) -> some View {
|
||||
aspectRatio(imageInfo?.aspectRatio, contentMode: .fill)
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static var previews: some View {
|
||||
ImageMediaEventsTimelineView(timelineItem: makeTimelineItem())
|
||||
.frame(width: 100, height: 100)
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
.previewLayout(.sizeThatFits)
|
||||
.background(.black)
|
||||
}
|
||||
|
||||
private static func makeTimelineItem() -> ImageRoomTimelineItem {
|
||||
ImageRoomTimelineItem(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(filename: "image.jpg",
|
||||
imageInfo: .mockImage,
|
||||
thumbnailInfo: .mockThumbnail,
|
||||
contentType: .jpeg))
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct SeparatorMediaEventsTimelineView: View {
|
||||
let group: MediaEventsTimelineGroup
|
||||
|
||||
var body: some View {
|
||||
Text(group.title)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.frame(alignment: .center)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct SeparatorMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
let item = SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Separator")),
|
||||
timestamp: .mock)
|
||||
|
||||
SeparatorMediaEventsTimelineView(group: .init(id: item.id.uniqueID.id,
|
||||
title: "Group",
|
||||
items: []))
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct VideoMediaEventsTimelineView: View {
|
||||
@Environment(\.timelineContext) private var context
|
||||
let timelineItem: VideoRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
thumbnail
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(L10n.commonVideo)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var thumbnail: some View {
|
||||
if let thumbnailSource = timelineItem.content.thumbnailInfo?.source {
|
||||
LoadableImage(mediaSource: thumbnailSource,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
size: timelineItem.content.thumbnailInfo?.size,
|
||||
mediaProvider: context?.mediaProvider) { imageView in
|
||||
imageView
|
||||
.overlay { playIcon }
|
||||
} placeholder: {
|
||||
placeholder
|
||||
}
|
||||
} else {
|
||||
playIcon
|
||||
}
|
||||
}
|
||||
|
||||
var playIcon: some View {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
var placeholder: some View {
|
||||
Rectangle()
|
||||
.foregroundColor(.compound.bgSubtleSecondary)
|
||||
.opacity(0.3)
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static var previews: some View {
|
||||
VideoMediaEventsTimelineView(timelineItem: makeTimelineItem())
|
||||
.frame(width: 100, height: 100)
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
.previewLayout(.sizeThatFits)
|
||||
.background(.black)
|
||||
}
|
||||
|
||||
private static func makeTimelineItem(caption: String? = nil, isEdited: Bool = false) -> VideoRoomTimelineItem {
|
||||
VideoRoomTimelineItem(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(filename: "video.mp4",
|
||||
videoInfo: .mockVideo,
|
||||
thumbnailInfo: .mockVideoThumbnail))
|
||||
}
|
||||
}
|
||||
+88
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user