ios: slider for voice messages (#2432)

* ios: slider for voice messages

* better layout hiding and showing

* properly stop playback when other media started

* better layout

* change padding

* code style

* refactor

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2023-05-14 20:07:34 +03:00
committed by GitHub
parent 0cfc9fd1fa
commit 0ec2468dce
9 changed files with 214 additions and 77 deletions

View File

@@ -102,11 +102,14 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
self.onFinishPlayback = onFinishPlayback
}
func start(fileName: String) {
func start(fileName: String, at: TimeInterval?) {
let url = getAppFilePath(fileName)
audioPlayer = try? AVAudioPlayer(contentsOf: url)
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
if let at = at {
audioPlayer?.currentTime = at
}
audioPlayer?.play()
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
@@ -125,6 +128,17 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
audioPlayer?.play()
}
func seek(_ to: TimeInterval) {
if audioPlayer?.isPlaying == true {
audioPlayer?.pause()
audioPlayer?.currentTime = to
audioPlayer?.play()
} else {
audioPlayer?.currentTime = to
}
self.onTimer?(to)
}
func stop() {
if let player = audioPlayer {
player.stop()

View File

@@ -13,14 +13,20 @@ struct CIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval?
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
Group {
if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) {
HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
playerTime()
player()
}
@@ -32,13 +38,16 @@ struct CIVoiceView: View {
HStack {
player()
playerTime()
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
.frame(alignment: .leading)
metaView().padding(.leading, -6)
}
}
}
.padding([.top, .horizontal], 4)
.padding(.top, 4)
.padding(.bottom, 8)
}
@@ -48,8 +57,11 @@ struct CIVoiceView: View {
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
showBackground: true,
seek: $seek,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
playbackTime: $playbackTime,
allowMenu: $allowMenu
)
}
@@ -61,6 +73,22 @@ struct CIVoiceView: View {
)
.foregroundColor(.secondary)
}
private func playbackSlider() -> some View {
ComposeVoiceView.SliderBar(
length: TimeInterval(duration),
progress: $playbackTime,
seek: {
let time = max(0.0001, $0)
seek(time)
playbackTime = time
})
.onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in
if !show {
allowMenu = true
}
}
}
private func metaView() -> some View {
CIMetaView(chatItem: chatItem)
@@ -95,10 +123,11 @@ struct VoiceMessagePlayer: View {
var recordingTime: TimeInterval
var showBackground: Bool
@State private var audioPlayer: AudioPlayer?
@Binding var seek: (TimeInterval) -> Void
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var startingPlayback: Bool = false
@Binding var allowMenu: Bool
var body: some View {
ZStack {
@@ -120,18 +149,24 @@ struct VoiceMessagePlayer: View {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
}
.onDisappear {
audioPlayer?.stop()
.onAppear {
seek = { to in audioPlayer?.seek(to) }
audioPlayer?.onTimer = { playbackTime = $0 }
audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingPlayback {
.onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} else {
startingPlayback = false
}
}
.onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback
}
}
@ViewBuilder private func playbackButton() -> some View {
@@ -204,7 +239,6 @@ struct VoiceMessagePlayer: View {
}
private func startPlayback(_ recordingFileName: String) {
startingPlayback = true
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
@@ -213,8 +247,7 @@ struct VoiceMessagePlayer: View {
playbackTime = TimeInterval(0)
}
)
audioPlayer?.start(fileName: recordingFileName)
playbackTime = TimeInterval(0)
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
playbackState = .playing
}
}
@@ -240,13 +273,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
playbackState: .playing,
playbackTime: TimeInterval(20)
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true)
)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)

View File

@@ -15,9 +15,15 @@ struct FramedCIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
HStack {
VoiceMessagePlayer(
@@ -25,8 +31,11 @@ struct FramedCIVoiceView: View {
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
showBackground: false,
seek: $seek,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
playbackTime: $playbackTime,
allowMenu: $allowMenu
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
@@ -35,12 +44,31 @@ struct FramedCIVoiceView: View {
)
.foregroundColor(.secondary)
.frame(width: 50, alignment: .leading)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
.padding(.top, 6)
.padding(.leading, 6)
.padding(.trailing, 12)
.padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0)
}
private func playbackSlider() -> some View {
ComposeVoiceView.SliderBar(
length: TimeInterval(duration),
progress: $playbackTime,
seek: {
let time = max(0.0001, $0)
seek(time)
playbackTime = time
})
.onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in
if !show {
allowMenu = true
}
}
}
}
struct FramedCIVoiceView_Previews: PreviewProvider {

File diff suppressed because one or more lines are too long

View File

@@ -82,7 +82,7 @@ struct ChatItemInfoView: View {
.padding(.vertical, 6)
.background(ciDirFrameColor(chatItemSent: chatItemSent, colorScheme: colorScheme))
.cornerRadius(18)
.uiKitContextMenu(menu: uiMenu)
.uiKitContextMenu(menu: uiMenu, allowMenu: Binding.constant(true))
Text(
localTimestamp(itemVersion.itemVersionTs)
+ (current

View File

@@ -16,6 +16,22 @@ struct ChatItemView: View {
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
self.chatInfo = chatInfo
self.chatItem = chatItem
self.showMember = showMember
self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy)
_revealed = revealed
_allowMenu = allowMenu
_audioPlayer = audioPlayer
_playbackState = playbackState
_playbackTime = playbackTime
}
var body: some View {
let ci = chatItem
@@ -25,7 +41,7 @@ struct ChatItemView: View {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration)
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
@@ -37,7 +53,7 @@ struct ChatItemView: View {
}
private func framedItemView() -> some View {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
}
}

View File

@@ -219,17 +219,25 @@ struct ChatView: View {
.padding(.vertical, 8)
}
private func voiceWithoutFrame(_ ci: ChatItem) -> Bool {
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil
}
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
return GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
let maxWidth =
cInfo.chatType == .group
? (g.size.width - 28) * 0.84 - 42
: (g.size.width - 32) * 0.84
LazyVStack(spacing: 5) {
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
let voiceNoFrame = voiceWithoutFrame(ci)
let maxWidth = cInfo.chatType == .group
? voiceNoFrame
? (g.size.width - 28) - 42
: (g.size.width - 28) * 0.84 - 42
: voiceNoFrame
? (g.size.width - 32)
: (g.size.width - 32) * 0.84
chatItemView(ci, maxWidth)
.scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear {
@@ -448,15 +456,21 @@ struct ChatView: View {
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
@State private var allowMenu: Bool = true
@State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval?
var body: some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
let uiMenu: Binding<UIMenu> = Binding(
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
set: { _ in }
)
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
.uiKitContextMenu(menu: uiMenu)
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
@@ -469,6 +483,14 @@ struct ChatView: View {
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.onDisappear {
if ci.content.msgContent?.isVoice == true {
allowMenu = true
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {

View File

@@ -38,7 +38,7 @@ struct ComposeVoiceView: View {
@State private var playbackTime: TimeInterval?
@State private var startingPlayback: Bool = false
private static let previewHeight: CGFloat = 50
private static let previewHeight: CGFloat = 55
var body: some View {
ZStack {
@@ -66,6 +66,7 @@ struct ComposeVoiceView: View {
}
}
.padding(.trailing, 12)
.padding(.top, 4)
ProgressBar(length: MAX_VOICE_MESSAGE_LENGTH, progress: $recordingTime)
}
@@ -105,9 +106,12 @@ struct ComposeVoiceView: View {
}
}
.padding(.trailing, 12)
.padding(.top, 4)
if let recordingLength = recordingTime {
ProgressBar(length: recordingLength, progress: $playbackTime)
GeometryReader { _ in
SliderBar(length: recordingLength, progress: $playbackTime, seek: { audioPlayer?.seek($0) })
}
}
}
.onChange(of: stopPlayback) { _ in
@@ -145,6 +149,18 @@ struct ComposeVoiceView: View {
}
}
struct SliderBar: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
var seek: (TimeInterval) -> Void
var body: some View {
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
.frame(maxWidth: .infinity)
.frame(height: 4)
}
}
private struct ProgressBar: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
@@ -154,10 +170,10 @@ struct ComposeVoiceView: View {
ZStack {
Rectangle()
.fill(Color.accentColor)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 3)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
.animation(.linear, value: progress)
}
.frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding
.frame(height: 4)
}
}
}
@@ -172,8 +188,7 @@ struct ComposeVoiceView: View {
playbackTime = recordingTime // animate progress bar to the end
}
)
audioPlayer?.start(fileName: recordingFileName)
playbackTime = TimeInterval(0)
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
playbackState = .playing
}
}

View File

@@ -11,11 +11,12 @@ import UIKit
import SwiftUI
extension View {
func uiKitContextMenu(menu: Binding<UIMenu>) -> some View {
self.overlay(Color(uiColor: .systemBackground))
.overlay(
InteractionView(content: self, menu: menu)
)
func uiKitContextMenu(menu: Binding<UIMenu>, allowMenu: Binding<Bool>) -> some View {
self.overlay {
if allowMenu.wrappedValue {
self.overlay(Color(uiColor: .systemBackground)).overlay(InteractionView(content: self, menu: menu))
}
}
}
}