diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityRecordingVoiceView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityRecordingVoiceView.swift new file mode 100644 index 0000000..eb95fd5 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityRecordingVoiceView.swift @@ -0,0 +1,216 @@ +// +// CreatorCommunityRecordingVoiceView.swift +// SodaLive +// +// Created by klaus on 8/7/24. +// + +import SwiftUI + +struct CreatorCommunityRecordingVoiceView: View { + enum RecordMode { + case RECORD, PLAY + } + + @StateObject var soundManager = CreatorCommunitySoundManager() + + @State var recordMode = RecordMode.RECORD + + @Binding var isShowing: Bool + @Binding var isShowPopup: Bool + @Binding var errorMessage: String + + @Binding var fileName: String + @Binding var soundData: Data? + + var body: some View { + ZStack { + Color.black.opacity(0.7) + .ignoresSafeArea() + + GeometryReader { proxy in + VStack { + Spacer() + + VStack { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("음성녹음") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + + Spacer() + + Image("ic_close_white") + .resizable() + .frame(width: 20, height: 20) + .onTapGesture { isShowing = false } + } + .padding(.horizontal, 26.7) + .padding(.top, 26.7) + } + + Text(soundManager.timeString) + .font(.custom(Font.bold.rawValue, size: 33.3)) + .foregroundColor(.white) + .padding(.top, 80) + + switch recordMode { + case .RECORD: + if !soundManager.isLoading { + Image(soundManager.isRecording ? "ic_record_stop" : "ic_record") + .resizable() + .frame(width: 70, height: 70) + .padding(.vertical, 52.3) + .onTapGesture { + if !soundManager.isLoading { + if !soundManager.isRecording { + let fileName = "\(Date().timeIntervalSince1970 * 1000).m4a" + self.fileName = fileName + soundManager.startRecording(fileName) + } else { + soundManager.stopRecording() + recordMode = .PLAY + } + } + } + } + + case .PLAY: + if !soundManager.isLoading { + VStack(spacing: 0) { + HStack(spacing: 0) { + Spacer() + + Text("삭제") + .font(.custom(Font.medium.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "bbbbbb").opacity(0)) + + Spacer() + + Image( + !soundManager.isPlaying ? + "ic_record_play" : + "ic_record_pause" + ) + .onTapGesture { + if !soundManager.isLoading { + if !soundManager.isPlaying { + soundManager.playAudio() + } else { + soundManager.stopAudio() + } + } + } + + Spacer() + + Text("삭제") + .font(.custom(Font.medium.rawValue, size: 15.3)) + .foregroundColor(Color.graybb) + .onTapGesture { + soundManager.stopAudio() + soundManager.deleteAudioFile() + recordMode = .RECORD + fileName = "녹음" + } + + Spacer() + } + .padding(.top, 80) + + HStack(spacing: 13.3) { + Text("다시 녹음") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color.button) + .frame(width: (proxy.size.width - 40) / 3, height: 50) + .background(Color.button.opacity(0.2)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.button, lineWidth: 1.3) + ) + .onTapGesture { + soundManager.stopAudio() + soundManager.deleteAudioFile() + recordMode = .RECORD + fileName = "녹음" + } + + Text("녹음완료") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + .frame(width: (proxy.size.width - 40) * 2 / 3, height: 50) + .background(Color.button) + .cornerRadius(10) + .onTapGesture { + do { + let soundData = try Data(contentsOf: soundManager.getAudioFileURL()) + self.soundData = soundData + self.isShowing = false + } catch { + errorMessage = "녹음파일을 생성하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + isShowPopup = true + } + } + } + .padding(.vertical, 40) + .padding(.horizontal, 13.3) + } + } + } + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color.gray22) + .frame(width: proxy.size.width, height: 15.3) + } + + if soundManager.isLoading { + LoadingView() + } + } + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + } + .edgesIgnoringSafeArea(.bottom) + .onAppear { + soundManager.prepareRecording() + } + } + } + .popup(isPresented: $soundManager.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(soundManager.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: screenSize().width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + .onDisappear { + if soundManager.onClose { + isShowing = false + } + } + } + } + } +} + +#Preview { + CreatorCommunityRecordingVoiceView( + isShowing: .constant(false), + isShowPopup: .constant(false), + errorMessage: .constant(""), + fileName: .constant(""), + soundData: .constant(nil) + ) +} diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunitySoundManager.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunitySoundManager.swift new file mode 100644 index 0000000..26a5c8e --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunitySoundManager.swift @@ -0,0 +1,188 @@ +// +// CreatorCommunitySoundManager.swift +// SodaLive +// +// Created by klaus on 8/7/24. +// + +import Foundation +import AVKit +import Combine + +class CreatorCommunitySoundManager: NSObject, ObservableObject { + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + @Published var onClose = false + + @Published var isPlaying = false + @Published var isRecording = false + + @Published var timeString = "00:00.00" + + private var timerSubscription: Cancellable? + private var startTime: Date? + + var player: AVAudioPlayer! + var audioRecorder: AVAudioRecorder! + + var fileName = "record.m4a" + + func prepareRecording() { + isLoading = true + + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playAndRecord, mode: .default) + try audioSession.setActive(true) + audioSession.requestRecordPermission() { [weak self] allowed in + DispatchQueue.main.async { + if !allowed { + self?.errorMessage = "권한을 허용하지 않으시면 음성녹음을 하실 수 없습니다." + self?.isShowPopup = true + self?.onClose = true + } + } + } + } catch { + errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + isShowPopup = true + onClose = true + } + + isLoading = false + } + + func startRecording(_ fileName: String) { + self.fileName = fileName + + isLoading = true + let settings = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 48000, + AVEncoderBitRateKey: 256000, + AVNumberOfChannelsKey: 2, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + do { + audioRecorder = try AVAudioRecorder(url: getAudioFileURL(), settings: settings) + audioRecorder.record() + isRecording = true + + startTimer() + } catch { + errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + isShowPopup = true + } + isLoading = false + } + + func stopRecording() { + stopTimer() + + audioRecorder?.stop() + audioRecorder = nil + isRecording = false + prepareForPlay() + } + + func prepareForPlay(_ url: URL? = nil) { + isLoading = true + + DispatchQueue.main.async { + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playback, mode: .default) + + if let url = url { + self.player = try AVAudioPlayer(data: Data(contentsOf: url)) + } else { + self.player = try AVAudioPlayer(contentsOf: self.getAudioFileURL()) + } + + self.player?.volume = 1 + self.player?.delegate = self + self.player?.prepareToPlay() + } catch { + self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + self.isShowPopup = true + } + + self.isLoading = false + } + } + + func playAudio() { + player?.play() + + isPlaying = player.isPlaying + startTimer() + } + + func stopAudio() { + stopTimer() + player?.stop() + player.currentTime = 0 + isPlaying = player.isPlaying + } + + func deleteAudioFile() { + do { + try FileManager.default.removeItem(at: getAudioFileURL()) + } catch {} + } + + func getAudioFileURL() -> URL { + return getDocumentsDirectory().appendingPathComponent(fileName) + } + + private func getDocumentsDirectory() -> URL { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + return paths[0] + } + + func startTimer() { + timeString = "00:00.00" + startTime = Date() + timerSubscription = Timer.publish(every: 0.01, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.updateTime() + } + } + + func stopTimer() { + timeString = "00:00.00" + timerSubscription?.cancel() + + startTime = nil + timerSubscription = nil + } + + private func updateTime() { + guard let startTime = startTime else { return } + + let elapsedTime = Date().timeIntervalSince(startTime) + + let minutes = Int(elapsedTime) / 60 + let seconds = Int(elapsedTime) % 60 + let centiseconds = Int((elapsedTime - Double(minutes * 60) - Double(seconds)) * 100) + + timeString = String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds) + } + + deinit { + player?.stop() + audioRecorder?.stop() + + startTime = nil + timerSubscription?.cancel() + } +} + +extension CreatorCommunitySoundManager: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + stopAudio() + } +} diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteView.swift index 3e073f9..438de37 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteView.swift @@ -12,7 +12,9 @@ struct CreatorCommunityWriteView: View { @StateObject var keyboardHandler = KeyboardHandler() @StateObject private var viewModel = CreatorCommunityWriteViewModel() + @State private var isShowRecordingVoiceView = false @State private var isShowPhotoPicker = false + @State private var fileName: String = "녹음" let onSuccess: () -> Void var body: some View { @@ -72,6 +74,37 @@ struct CreatorCommunityWriteView: View { .frame(maxWidth: .infinity) .padding(.top, 24) + if let _ = viewModel.postImage { + VStack(spacing: 13.3) { + HStack(spacing: 0) { + Text("오디오 녹음") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color.grayee) + + Spacer() + } + + Text(fileName) + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color.main) + .padding(.vertical, 8) + .background(Color.bg) + .cornerRadius(5.3) + .frame(maxWidth: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 5.3) + .stroke(Color.button, lineWidth: 1) + ) + .onTapGesture { isShowRecordingVoiceView = true } + + + Text("※ 오디오 녹음은 최대 3분입니다") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color.gray77) + } + .padding(.top, 24) + } + HStack(spacing: 0) { Text("내용") .font(.custom(Font.bold.rawValue, size: 16.7)) @@ -243,6 +276,7 @@ struct CreatorCommunityWriteView: View { .onTapGesture { hideKeyboard() viewModel.createCommunityPost { + deleteAudioFile() AppState.shared.back() DispatchQueue.main.async { @@ -280,6 +314,16 @@ struct CreatorCommunityWriteView: View { sourceType: .photoLibrary ) } + + if isShowRecordingVoiceView { + CreatorCommunityRecordingVoiceView( + isShowing: $isShowRecordingVoiceView, + isShowPopup: $viewModel.isShowPopup, + errorMessage: $viewModel.errorMessage, + fileName: $fileName, + soundData: $viewModel.soundData + ) + } } .onTapGesture { hideKeyboard() } .edgesIgnoringSafeArea(.bottom) @@ -303,6 +347,21 @@ struct CreatorCommunityWriteView: View { } } } + + private func deleteAudioFile() { + do { + try FileManager.default.removeItem(at: getAudioFileURL()) + } catch {} + } + + private func getAudioFileURL() -> URL { + return getDocumentsDirectory().appendingPathComponent(fileName) + } + + private func getDocumentsDirectory() -> URL { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + return paths[0] + } } struct CreatorCommunityWriteView_Previews: PreviewProvider { diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteViewModel.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteViewModel.swift index 6e88353..3f4f949 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteViewModel.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteViewModel.swift @@ -44,6 +44,7 @@ final class CreatorCommunityWriteViewModel: ObservableObject { } } @Published var price = 0 + @Published var soundData: Data? = nil var placeholder = "내용을 입력하세요" @@ -65,7 +66,19 @@ final class CreatorCommunityWriteViewModel: ObservableObject { provider: .data(imageData), name: "postImage", fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).jpg", - mimeType: "image/*") + mimeType: "image/*" + ) + ) + } + + if let soundData = soundData { + multipartData.append( + MultipartFormData( + provider: .data(soundData), + name: "audioFile", + fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).m4a", + mimeType: "audio/m4a" + ) ) }