diff --git a/SodaLive/Sources/Audition/Applicant/AuditionApplicantItemView.swift b/SodaLive/Sources/Audition/Applicant/AuditionApplicantItemView.swift index d8b2448..3325340 100644 --- a/SodaLive/Sources/Audition/Applicant/AuditionApplicantItemView.swift +++ b/SodaLive/Sources/Audition/Applicant/AuditionApplicantItemView.swift @@ -13,6 +13,8 @@ struct AuditionApplicantItemView: View { let item: GetAuditionRoleApplicantItem let onClickVote: (Int) -> Void + @StateObject var soundManager = AuditionSoundManager.shared + var body: some View { VStack(spacing: 5.3) { HStack(spacing: 13.3) { @@ -26,8 +28,12 @@ struct AuditionApplicantItemView: View { .frame(width: 40, height: 40) .clipShape(Circle()) - Image("ic_audition_play") + Image(soundManager.applicantId == item.applicantId && soundManager.isPlaying ? "ic_audition_pause" : "ic_audition_play") .onTapGesture { + soundManager.playOrPause( + applicantId: item.applicantId, + url: item.voiceUrl + ) } } @@ -38,6 +44,17 @@ struct AuditionApplicantItemView: View { .foregroundColor(Color.white) Spacer() + + if soundManager.applicantId == item.applicantId { + Text("\(secondsToMinutesSeconds(Int(soundManager.currentTime)))/\(secondsToMinutesSeconds(Int(soundManager.duration)))") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color.gray77) + } + } + + if soundManager.applicantId == item.applicantId { + ProgressView(value: soundManager.currentTime, total: soundManager.duration) + .progressViewStyle(LinearProgressViewStyle(tint: Color.button)) } } @@ -60,6 +77,13 @@ struct AuditionApplicantItemView: View { } .padding(.horizontal, 13.3) } + + private func secondsToMinutesSeconds(_ seconds: Int) -> String { + let minute = String(format: "%02d", seconds / 60) + let second = String(format: "%02d", seconds % 60) + + return "\(minute):\(second)" + } } #Preview { diff --git a/SodaLive/Sources/Audition/Detail/AuditionSoundManager.swift b/SodaLive/Sources/Audition/Detail/AuditionSoundManager.swift new file mode 100644 index 0000000..92b31b8 --- /dev/null +++ b/SodaLive/Sources/Audition/Detail/AuditionSoundManager.swift @@ -0,0 +1,126 @@ +// +// AuditionSoundManager.swift +// SodaLive +// +// Created by klaus on 1/7/25. +// + +import Foundation +import AVKit +import Combine + +class AuditionSoundManager: NSObject, ObservableObject { + static let shared = AuditionSoundManager() + + @Published private (set) var isPlaying = false + + @Published var isLoading = false + @Published var errorMessage = "" + @Published var isShowPopup = false + + @Published var currentTime: Double = 0.0 + @Published private (set) var duration: Double = 0.0 + + private var player: AVAudioPlayer! + private var timer: Timer? + @Published private (set) var applicantId = -1 + + func resetPlayer() { + stop() + } + + func playOrPause(applicantId: Int, url: String) { + if applicantId <= 0 { + stop() + } + + if self.applicantId != applicantId { + stop() + setupPlayer(with: url) + self.applicantId = applicantId + } else { + if !isPlaying { + player?.play() + isPlaying = player?.isPlaying ?? false + startTimer() + } else { + player?.pause() + isPlaying = player?.isPlaying ?? false + stopTimer() + } + } + } + + private func stop() { + stopTimer() + isPlaying = false + isLoading = false + player?.stop() + player?.currentTime = 0 + currentTime = 0.0 + duration = 0.0 + applicantId = -1 + } + + private func setupPlayer(with url: String) { + guard let url = URL(string: url) else { + self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + self.isShowPopup = true + return + } + + isLoading = true + URLSession.shared.dataTask(with: url) { [unowned self] data, response, error in + guard let audioData = data else { + self.isLoading = false + return + } + + do { + self.player = try AVAudioPlayer(data: audioData) + + DispatchQueue.main.async { + self.player?.volume = 1 + self.player?.delegate = self + self.player?.prepareToPlay() + + self.player?.play() + self.duration = self.player?.duration ?? 0.0 + self.isPlaying = self.player.isPlaying + self.isLoading = false + self.startTimer() + } + } catch { + DispatchQueue.main.async { + self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + self.isShowPopup = true + self.isLoading = false + } + } + }.resume() + } + + private func startTimer() { + stopTimer() // 기존 타이머 제거 + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.updateCurrentTime() + } + } + + // 타이머 정지 + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func updateCurrentTime() { + guard let audioPlayer = player else { return } + currentTime = audioPlayer.currentTime + } +} + +extension AuditionSoundManager: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + stop() + } +} diff --git a/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift b/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift index c5c574b..7dd4d29 100644 --- a/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift +++ b/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift @@ -14,6 +14,7 @@ struct AuditionRoleDetailView: View { @StateObject var viewModel = AuditionRoleDetailViewModel() @StateObject var keyboardHandler = KeyboardHandler() + @StateObject var soundManager = AuditionSoundManager.shared @State private var isShowApplyMethodView = false @State private var isShowSelectAudioView = false @@ -123,10 +124,28 @@ struct AuditionRoleDetailView: View { Spacer() } } + .popup(isPresented: $soundManager.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { + HStack { + Spacer() + Text(soundManager.errorMessage) + .padding(.vertical, 13.3) + .frame(width: screenSize().width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .cornerRadius(20) + .padding(.bottom, 66.7) + Spacer() + } + } .onAppear { viewModel.onFailure = { AppState.shared.back() } viewModel.auditionRoleId = roleId } + .onDisappear { + soundManager.resetPlayer() + } if let roleDetail = viewModel.auditionRoleDetail { Text(roleDetail.isAlreadyApplicant ? "오디션 재지원" : "오디션 지원") @@ -208,6 +227,10 @@ struct AuditionRoleDetailView: View { viewModel.isShowNotifyVote = false } } + + if soundManager.isLoading { + LoadingView() + } } }