diff --git a/SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/Contents.json new file mode 100644 index 0000000..a988c1b --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_player_pause.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/ic_player_pause.png b/SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/ic_player_pause.png new file mode 100644 index 0000000..12310d9 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/ic_player_pause.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/Contents.json new file mode 100644 index 0000000..90a710f --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_player_play.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/ic_player_play.png b/SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/ic_player_play.png new file mode 100644 index 0000000..819eb53 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/ic_player_play.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/Contents.json new file mode 100644 index 0000000..5ee5739 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_playlist.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/ic_playlist.png b/SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/ic_playlist.png new file mode 100644 index 0000000..0127d74 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/ic_playlist.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/Contents.json new file mode 100644 index 0000000..05b3660 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_skip_back.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/ic_skip_back.png b/SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/ic_skip_back.png new file mode 100644 index 0000000..8846e1f Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/ic_skip_back.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/Contents.json new file mode 100644 index 0000000..7081b66 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_skip_forward.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/ic_skip_forward.png b/SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/ic_skip_forward.png new file mode 100644 index 0000000..2fb1d3e Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/ic_skip_forward.png differ diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index b10c654..2934709 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -18,6 +18,7 @@ class AppState: ObservableObject { didSet { if isShowPlayer { ContentPlayManager.shared.stopAudio() + ContentPlayerPlayManager.shared.resetPlayer() } } } diff --git a/SodaLive/Sources/Content/ContentApi.swift b/SodaLive/Sources/Content/ContentApi.swift index 49e48b0..962155e 100644 --- a/SodaLive/Sources/Content/ContentApi.swift +++ b/SodaLive/Sources/Content/ContentApi.swift @@ -37,6 +37,7 @@ enum ContentApi { case pinContent(contentId: Int) case unpinContent(contentId: Int) case getAudioContentByTheme(themeId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort) + case generateUrl(contentId: Int) } extension ContentApi: TargetType { @@ -129,6 +130,9 @@ extension ContentApi: TargetType { case .getAudioContentByTheme(let themeId, _, _, _, _, _): return "/audio-content/theme/\(themeId)/content" + + case .generateUrl(let contentId): + return "/audio-content/\(contentId)/generate-url" } } @@ -137,7 +141,7 @@ extension ContentApi: TargetType { case .getAudioContentList, .getAudioContentDetail, .getOrderList, .getAudioContentThemeList, .getAudioContentCommentList, .getAudioContentCommentReplyList, .getNewContentOfTheme, .getNewContentThemeList, .getNewContentAllOfTheme, .getAudioContentListByCurationId, .getContentRanking, - .getContentRankingSortType: + .getContentRankingSortType, .generateUrl: return .get case .getMainBannerList, .getMainOrderList, .getNewContentUploadCreatorList, .getCurationList, .getAudioContentByTheme: @@ -195,7 +199,7 @@ extension ContentApi: TargetType { case .addAllPlaybackTracking(let request): return .requestJSONEncodable(request) - case .getAudioContentThemeList, .getMainBannerList, .getMainOrderList, .getNewContentUploadCreatorList: + case .getAudioContentThemeList, .getMainBannerList, .getMainOrderList, .getNewContentUploadCreatorList, .generateUrl: return .requestPlain case .uploadAudioContent(let parameters): diff --git a/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift b/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift index 089aa03..db334f5 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift @@ -55,6 +55,9 @@ struct ContentDetailPlayView: View { } else if audioContent.releaseDate == nil && !isAlertPreview || (audioContent.isActivePreview && !audioContent.contentUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { Image(isPlaying() ? "btn_audio_content_pause" : isAlertPreview ? "btn_audio_content_preview_play" : "btn_audio_content_play") .onTapGesture { + ContentPlayManager.shared.stopAudio() + ContentPlayerPlayManager.shared.resetPlayer() + if isPlaying() { contentPlayManager.pauseAudio() } else { diff --git a/SodaLive/Sources/Content/Player/AudioContentPlaylistManager.swift b/SodaLive/Sources/Content/Player/AudioContentPlaylistManager.swift new file mode 100644 index 0000000..0817071 --- /dev/null +++ b/SodaLive/Sources/Content/Player/AudioContentPlaylistManager.swift @@ -0,0 +1,40 @@ +// +// AudioContentPlaylistManager.swift +// SodaLive +// +// Created by klaus on 12/16/24. +// + +import Foundation + +class AudioContentPlaylistManager { + private var currentIndex = -1 + + let playlist: [AudioContentPlaylistContent] + + init(playlist: [AudioContentPlaylistContent]) { + self.playlist = playlist + } + + func moveToNext() -> AudioContentPlaylistContent? { + if !playlist.isEmpty { + currentIndex = currentIndex + 1 >= playlist.count ? 0 : currentIndex + 1 + return playlist[currentIndex] + } + + return nil + } + + func moveToPrevious() -> AudioContentPlaylistContent? { + if !playlist.isEmpty { + currentIndex = currentIndex - 1 < 0 ? playlist.count - 1 : currentIndex - 1 + return playlist[currentIndex] + } + + return nil + } + + func hasNextContent() -> Bool { + return currentIndex + 1 < playlist.count + } +} diff --git a/SodaLive/Sources/Content/Player/ContentGenerateUrlRepository.swift b/SodaLive/Sources/Content/Player/ContentGenerateUrlRepository.swift new file mode 100644 index 0000000..4dd1798 --- /dev/null +++ b/SodaLive/Sources/Content/Player/ContentGenerateUrlRepository.swift @@ -0,0 +1,18 @@ +// +// ContentGenerateUrlRepository.swift +// SodaLive +// +// Created by klaus on 12/17/24. +// + +import CombineMoya +import Combine +import Moya + +class ContentGenerateUrlRepository { + private let api = MoyaProvider() + + func generateUrl(contentId: Int) -> AnyPublisher { + return api.requestPublisher(.generateUrl(contentId: contentId)) + } +} diff --git a/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift b/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift new file mode 100644 index 0000000..3405242 --- /dev/null +++ b/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift @@ -0,0 +1,326 @@ +// +// ContentPlayerPlayManager.swift +// SodaLive +// +// Created by klaus on 12/16/24. +// + +import Foundation +import AVKit +import MediaPlayer +import Combine + +final class ContentPlayerPlayManager: NSObject, ObservableObject { + static let shared = ContentPlayerPlayManager() + + private let repository = ContentGenerateUrlRepository() + + @Published var title = "" + @Published var nickname = "" + @Published var coverImageUrl = "" + @Published var creatorProfileUrl = "" + + @Published private (set) var isShowingMiniPlayer = false + @Published private (set) var isPlaying = false + + @Published var isLoading = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isShowPlaylist = false + @Published var playlist: [AudioContentPlaylistContent] = [] + @Published var currentTime: Double = 0.0 + @Published private (set) var duration: Double = 0.0 + @Published var isEditing = false + + var player: AVPlayer? + private var cancellables = Set() + + @Published var bufferedTime: Double = 0 // 현재 버퍼링된 시간 + @Published var isPlaybackLikelyToKeepUp: Bool = false // 재생 가능 상태 + + let minimumBufferedTime: Double = 5.0 + + var playlistManager: AudioContentPlaylistManager? = nil + + override init() { + self.player = AVPlayer() + super.init() + } + + func setPlaylist(playlist: [AudioContentPlaylistContent]) { + resetPlayer() + self.playlist = playlist + playlistManager = AudioContentPlaylistManager(playlist: playlist) + playNextContent() + isShowingMiniPlayer = true + UIApplication.shared.beginReceivingRemoteControlEvents() + } + + private func setupPlayer(with url: URL) { + // 기존 playerItem 관련 Combine 구독 해제 + cancellables.removeAll() + + // 새로운 AVPlayerItem 생성 + let playerItem = AVPlayerItem(url: url) + self.player?.replaceCurrentItem(with: playerItem) + self.player? + .periodicTimePublisher(interval: CMTime(seconds: 0.5, preferredTimescale: 600)) + .sink { [weak self] currentTime in + if !(self?.isEditing ?? false) { + self?.currentTime = CMTimeGetSeconds(currentTime) + } + } + .store(in: &cancellables) + + playerItem.publisher(for: \.duration) + .map { CMTimeGetSeconds($0) } + .filter { !$0.isNaN } // NaN 방지 + .sink { [weak self] duration in + self?.duration = duration + } + .store(in: &cancellables) + + playerItem.publisher(for: \.loadedTimeRanges) + .compactMap { $0.first?.timeRangeValue } + .map { CMTimeGetSeconds($0.start) + CMTimeGetSeconds($0.duration) } + .receive(on: DispatchQueue.main) + .assign(to: &$bufferedTime) + + playerItem.publisher(for: \.isPlaybackLikelyToKeepUp) + .receive(on: DispatchQueue.main) + .assign(to: &$isPlaybackLikelyToKeepUp) + + // CombineLatest로 버퍼링 및 재생 조건 확인 + Publishers.CombineLatest($bufferedTime, $isPlaybackLikelyToKeepUp) + .receive(on: DispatchQueue.main) + .sink { [weak self] bufferedTime, isLikelyToKeepUp in + self?.checkPlaybackStart(bufferedTime: bufferedTime, isLikelyToKeepUp: isLikelyToKeepUp) + } + .store(in: &cancellables) + + // Combine으로 재생 완료 이벤트 감지 + NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: playerItem) + .sink { [weak self] _ in + DispatchQueue.main.async { + self?.handlePlaybackEnded() + } + } + .store(in: &cancellables) + } + + private func checkPlaybackStart(bufferedTime: Double, isLikelyToKeepUp: Bool) { + if bufferedTime >= minimumBufferedTime && isLikelyToKeepUp { + if !isPlaying { + player?.play() + isPlaying = true + isLoading = false + DEBUG_LOG("재생 시작: \(bufferedTime)초 버퍼링") + } + } else { + if isPlaying { + player?.pause() + isPlaying = false + isLoading = true + DEBUG_LOG("재생 중단: 버퍼링 부족 (\(bufferedTime)초)") + } + } + } + + private func handlePlaybackEnded() { + playNextContent() + } + + func playNextContent() { + stop() + + if let content = playlistManager?.moveToNext() { + generateUrl(contentId: content.id) { [unowned self] url in + self.urlGenerateSuccess(content: content, url: url) + } onFailure: { + if let hasNextContent = self.playlistManager?.hasNextContent(), hasNextContent { + self.playNextContent() + } else { + self.stop() + } + } + } + } + + func playPreviousContent() { + stop() + + if let content = playlistManager?.moveToPrevious() { + generateUrl(contentId: content.id) { [unowned self] url in + self.urlGenerateSuccess(content: content, url: url) + } onFailure: { + if let hasNextContent = self.playlistManager?.hasNextContent(), hasNextContent { + self.playNextContent() + } else { + self.stop() + } + } + } + } + + private func urlGenerateSuccess(content: AudioContentPlaylistContent, url: String) { + title = content.title + nickname = content.creatorNickname + coverImageUrl = content.coverUrl + creatorProfileUrl = content.creatorProfileUrl + setupPlayer(with: URL(string: url)!) + } + + func playOrPause() { + if player?.timeControlStatus == .paused { + player?.play() + isPlaying = true + } else { + player?.pause() + isPlaying = false + } + } + + private func stop() { + isPlaying = false + isLoading = false + player?.pause() + player?.seek(to: .zero) + duration = 0 + currentTime = 0 + } + + func resetPlayer() { + stop() + isShowingMiniPlayer = false + + title = "" + nickname = "" + coverImageUrl = "" + creatorProfileUrl = "" + + cancellables.removeAll() + playlistManager = nil + unRegisterRemoteControlEvents() + } + + func seek(to time: Double) { + let cmTime = CMTime(seconds: time, preferredTimescale: 600) + player?.seek(to: cmTime) + } + + private func generateUrl(contentId: Int, onSuccess: @escaping (String) -> Void, onFailure: @escaping () -> Void) { + if contentId < 0 { + onFailure() + } + + if !isLoading { + isLoading = true + + repository.generateUrl(contentId: contentId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + onSuccess(data.contentUrl) + } else { + self.isLoading = false + onFailure() + } + } catch { + self.isLoading = false + onFailure() + } + } + .store(in: &cancellables) + } + } + + private func registerNowPlayingInfoCenter() { + let center = MPNowPlayingInfoCenter.default() + var nowPlayingInfo = center.nowPlayingInfo ?? [String: Any]() + + nowPlayingInfo[MPMediaItemPropertyTitle] = title + nowPlayingInfo[MPMediaItemPropertyArtist] = nickname + if let artworkURL = URL(string: coverImageUrl), let imageData = try? Data(contentsOf: artworkURL), let artworkImage = UIImage(data: imageData) { + let artwork = MPMediaItemArtwork(boundsSize: artworkImage.size) { size in + return artworkImage + } + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork + } + + if let player = player { + // 콘텐츠 총 길이 + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration ?? .zero + // 콘텐츠 재생 시간에 따른 progressBar 초기화 + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate + // 콘텐츠 현재 재생시간 + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime + } + + center.nowPlayingInfo = nowPlayingInfo + } + + private func registerRemoteControlEvents() { + let center = MPRemoteCommandCenter.shared() + + center.playCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in + self.playOrPause() + return .success + } + + center.pauseCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in + self.playOrPause() + return .success + } + + center.skipForwardCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in + self.playNextContent() + return .success + } + + center.skipBackwardCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in + self.playPreviousContent() + return .success + } + } + + private func unRegisterRemoteControlEvents() { + let center = MPRemoteCommandCenter.shared() + center.playCommand.removeTarget(nil) + center.pauseCommand.removeTarget(nil) + center.skipForwardCommand.removeTarget(nil) + center.skipBackwardCommand.removeTarget(nil) + UIApplication.shared.endReceivingRemoteControlEvents() + } +} + +extension AVPlayer { + /// AVPlayer의 addPeriodicTimeObserver를 Combine Publisher로 변환 + func periodicTimePublisher(interval: CMTime, queue: DispatchQueue = .main) -> AnyPublisher { + let subject = PassthroughSubject() + + // Periodic Time Observer 추가 + let timeObserverToken = self.addPeriodicTimeObserver(forInterval: interval, queue: queue) { time in + subject.send(time) // 현재 시간을 스트림으로 보냄 + } + + // Subscription이 끝나면 Observer 제거 + return subject + .handleEvents(receiveCancel: { [weak self] in + self?.removeTimeObserver(timeObserverToken) + }) + .eraseToAnyPublisher() + } +} diff --git a/SodaLive/Sources/Content/Player/ContentPlayerView.swift b/SodaLive/Sources/Content/Player/ContentPlayerView.swift new file mode 100644 index 0000000..1148f83 --- /dev/null +++ b/SodaLive/Sources/Content/Player/ContentPlayerView.swift @@ -0,0 +1,189 @@ +// +// ContentPlayerView.swift +// SodaLive +// +// Created by klaus on 12/16/24. +// + +import SwiftUI +import Kingfisher +import Sliders + +struct ContentPlayerView: View { + + @StateObject var playerManager = ContentPlayerPlayManager.shared + + @Binding var isShowing: Bool + let playlist: [AudioContentPlaylistContent] + + var body: some View { + BaseView(isLoading: $playerManager.isLoading) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Spacer() + + Image("ic_bottom_white") + .onTapGesture { isShowing = false } + } + + Text(playerManager.title) + .font(.custom(Font.medium.rawValue, size: 16)) + .foregroundColor(.grayee) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + + HStack(spacing: 5.3) { + KFImage(URL(string: playerManager.creatorProfileUrl)) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: 26.7, height: 26.7)) + .resizable() + .scaledToFill() + .frame(width: 26.7, height: 26.7) + .clipShape(Circle()) + + Text(playerManager.nickname) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.gray90) + + Spacer() + } + .padding(.top, 21) + + Spacer() + + if playerManager.isShowPlaylist { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(0.. 0 { + ValueSlider( + value: $playerManager.currentTime, + in: 0...playerManager.duration, + step: 1.0, + onEditingChanged: { editing in + playerManager.isEditing = editing + if !editing { + playerManager.seek(to: playerManager.currentTime) + } + } + ) + .valueSliderStyle( + HorizontalValueSliderStyle( + track: HorizontalValueTrack( + view: Rectangle().foregroundColor(Color.button), + mask: Rectangle() + ) + .background(Rectangle().foregroundColor(Color.gray97.opacity(0.3))) + .frame(height: 5.3), + thumbSize: CGSize(width: 10, height: 10), + options: .interactiveTrack + ) + ) + .frame(height: 11) + } + + HStack(spacing: 0) { + Text(secondsToMinutesSeconds(seconds: Int(playerManager.currentTime))) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(.graybb) + + Spacer() + + Text(secondsToMinutesSeconds(seconds: Int(playerManager.duration))) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(.gray77) + } + .padding(.top, 5.3) + + HStack(spacing: 0) { + Image("ic_skip_back") + .onTapGesture { + playerManager.playPreviousContent() + } + + Spacer() + + Image(playerManager.isPlaying ? "ic_player_pause" : "ic_player_play") + .onTapGesture { + playerManager.playOrPause() + } + + Spacer() + + Image("ic_skip_forward") + .onTapGesture { + playerManager.playNextContent() + } + } + .padding(.vertical, 21) + + HStack(spacing: 0) { + Spacer() + + Image("ic_playlist") + .padding(5) + .background(Color.gray33.opacity(playerManager.isShowPlaylist ? 1 : 0)) + .cornerRadius(playerManager.isShowPlaylist ? 6.7 : 0) + .onTapGesture { playerManager.isShowPlaylist.toggle() } + } + } + .padding(20) + .onAppear { + if !playlist.isEmpty { + playerManager.setPlaylist(playlist: playlist) + } + } + } + } + + private func secondsToMinutesSeconds(seconds: Int) -> String { + let hours = String(format: "%02d", seconds / 3600) + let minute = String(format: "%02d", (seconds % 3600) / 60) + let second = String(format: "%02d", seconds % 60) + + return "\(hours):\(minute):\(second)" + } +} + +#Preview { + ContentPlayerView( + isShowing: .constant(true), + playlist: [ + AudioContentPlaylistContent( + id: 1, + title: "안녕하세요 오늘은 커버곡을 들려드리려고 해요", + category: "커버곡", + coverUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + duration: "00:30:20", + creatorNickname: "유저1", + creatorProfileUrl: "https://test-cf.sodalive.net/profile/default-profile.png" + ), + + AudioContentPlaylistContent( + id: 2, + title: "안녕하세요 오늘은 커버곡을 들려드리려고 해요안녕하세요 오늘은 커버곡을 들려드리려고 해요안녕하세요 오늘은 커버곡을 들려드리려고 해요안녕하세요 오늘은 커버곡을 들려드리려고 해요안녕하세요 오늘은 커버곡을 들려드리려고 해요안녕하세요 오늘은 커버곡을 들려드리려고 해요", + category: "커버곡", + coverUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + duration: "00:30:20", + creatorNickname: "유저1", + creatorProfileUrl: "https://test-cf.sodalive.net/profile/default-profile.png" + ) + ] + ) +} diff --git a/SodaLive/Sources/Content/Player/GenerateUrlResponse.swift b/SodaLive/Sources/Content/Player/GenerateUrlResponse.swift new file mode 100644 index 0000000..0c73309 --- /dev/null +++ b/SodaLive/Sources/Content/Player/GenerateUrlResponse.swift @@ -0,0 +1,10 @@ +// +// GenerateUrlResponse.swift +// SodaLive +// +// Created by klaus on 12/17/24. +// + +struct GenerateUrlResponse: Decodable { + let contentUrl: String +} diff --git a/SodaLive/Sources/Content/Playlist/Detail/ContentPlaylistDetailView.swift b/SodaLive/Sources/Content/Playlist/Detail/ContentPlaylistDetailView.swift index 43b00ec..44c43a7 100644 --- a/SodaLive/Sources/Content/Playlist/Detail/ContentPlaylistDetailView.swift +++ b/SodaLive/Sources/Content/Playlist/Detail/ContentPlaylistDetailView.swift @@ -10,6 +10,7 @@ import Kingfisher struct ContentPlaylistDetailView: View { @StateObject var viewModel = ContentPlaylistDetailViewModel() + @StateObject var contentPlayerPlayManager = ContentPlayerPlayManager.shared let playlistId: Int @Binding var isShowing: Bool @@ -17,8 +18,10 @@ struct ContentPlaylistDetailView: View { @State private var isShowPopupMenu = false @State private var isShowDeleteConfirm = false + @State private var isShowPlayer = false @State private var isShowModify = false + @State private var playlist: [AudioContentPlaylistContent] = [] var body: some View { BaseView(isLoading: $viewModel.isLoading) { @@ -148,6 +151,9 @@ struct ContentPlaylistDetailView: View { .cornerRadius(5.3) .contentShape(Rectangle()) .onTapGesture { + ContentPlayManager.shared.stopAudio() + playlist = response.contentList + isShowPlayer = true } HStack(spacing: 5.3) { @@ -163,6 +169,9 @@ struct ContentPlaylistDetailView: View { .cornerRadius(5.3) .contentShape(Rectangle()) .onTapGesture { + ContentPlayManager.shared.stopAudio() + playlist = response.contentList.shuffled() + isShowPlayer = true } } .padding(.top, 18) @@ -177,6 +186,57 @@ struct ContentPlaylistDetailView: View { .padding(.horizontal, 13.3) } } + + if contentPlayerPlayManager.isShowingMiniPlayer { + HStack(spacing: 0) { + KFImage(URL(string: contentPlayerPlayManager.coverImageUrl)) + .cancelOnDisappear(true) + .downsampling( + size: CGSize( + width: 36.7, + height: 36.7 + ) + ) + .resizable() + .frame(width: 36.7, height: 36.7) + .cornerRadius(5.3) + + VStack(alignment: .leading, spacing: 2.3) { + Text(contentPlayerPlayManager.title) + .font(.custom(Font.medium.rawValue, size: 13)) + .foregroundColor(Color.grayee) + .lineLimit(2) + + Text(contentPlayerPlayManager.nickname) + .font(.custom(Font.medium.rawValue, size: 11)) + .foregroundColor(Color.grayd2) + } + .padding(.horizontal, 10.7) + + Spacer() + + Image(contentPlayerPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play") + .resizable() + .frame(width: 25, height: 25) + .onTapGesture { + contentPlayerPlayManager.playOrPause() + } + + Image("ic_noti_stop") + .resizable() + .frame(width: 25, height: 25) + .padding(.leading, 16) + .onTapGesture { contentPlayerPlayManager.resetPlayer() } + } + .padding(.vertical, 10.7) + .padding(.horizontal, 13.3) + .background(Color.gray22) + .contentShape(Rectangle()) + .onTapGesture { + playlist = [] + isShowPlayer = true + } + } } .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { HStack { @@ -260,6 +320,10 @@ struct ContentPlaylistDetailView: View { reloadData: $reloadData ) } + + if isShowPlayer { + ContentPlayerView(isShowing: $isShowPlayer, playlist: playlist) + } } } } diff --git a/SodaLive/Sources/Main/Home/HomeView.swift b/SodaLive/Sources/Main/Home/HomeView.swift index c0201cc..0580752 100644 --- a/SodaLive/Sources/Main/Home/HomeView.swift +++ b/SodaLive/Sources/Main/Home/HomeView.swift @@ -18,12 +18,15 @@ struct HomeView: View { @StateObject var liveViewModel = LiveViewModel() @StateObject var appState = AppState.shared @StateObject var contentPlayManager = ContentPlayManager.shared + @StateObject var contentPlayerPlayManager = ContentPlayerPlayManager.shared private let liveView = LiveView() private let explorer = ExplorerView() private let messageView = MessageView() private let contentView = ContentMainView() + @State private var isShowPlayer = false + var body: some View { GeometryReader { proxy in ZStack(alignment: .bottom) { @@ -53,6 +56,56 @@ struct HomeView: View { Spacer() + if contentPlayerPlayManager.isShowingMiniPlayer { + HStack(spacing: 0) { + KFImage(URL(string: contentPlayerPlayManager.coverImageUrl)) + .cancelOnDisappear(true) + .downsampling( + size: CGSize( + width: 36.7, + height: 36.7 + ) + ) + .resizable() + .frame(width: 36.7, height: 36.7) + .cornerRadius(5.3) + + VStack(alignment: .leading, spacing: 2.3) { + Text(contentPlayerPlayManager.title) + .font(.custom(Font.medium.rawValue, size: 13)) + .foregroundColor(Color.grayee) + .lineLimit(2) + + Text(contentPlayerPlayManager.nickname) + .font(.custom(Font.medium.rawValue, size: 11)) + .foregroundColor(Color.grayd2) + } + .padding(.horizontal, 10.7) + + Spacer() + + Image(contentPlayerPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play") + .resizable() + .frame(width: 25, height: 25) + .onTapGesture { + contentPlayerPlayManager.playOrPause() + } + + Image("ic_noti_stop") + .resizable() + .frame(width: 25, height: 25) + .padding(.leading, 16) + .onTapGesture { contentPlayerPlayManager.resetPlayer() } + } + .padding(.vertical, 10.7) + .padding(.horizontal, 13.3) + .background(Color.gray22) + .contentShape(Rectangle()) + .onTapGesture { + isShowPlayer = true + } + } + if contentPlayManager.isShowingMiniPlayer { HStack(spacing: 0) { KFImage(URL(string: contentPlayManager.coverImage)) @@ -70,12 +123,12 @@ struct HomeView: View { VStack(alignment: .leading, spacing: 2.3) { Text(contentPlayManager.title) .font(.custom(Font.medium.rawValue, size: 13)) - .foregroundColor(Color(hex: "eeeeee")) + .foregroundColor(Color.grayee) .lineLimit(2) Text(contentPlayManager.nickname) .font(.custom(Font.medium.rawValue, size: 11)) - .foregroundColor(Color(hex: "d2d2d2")) + .foregroundColor(Color.grayd2) } .padding(.horizontal, 10.7) @@ -101,7 +154,7 @@ struct HomeView: View { } .padding(.vertical, 10.7) .padding(.horizontal, 13.3) - .background(Color(hex: "222222")) + .background(Color.gray22) .contentShape(Rectangle()) .onTapGesture { appState @@ -115,7 +168,7 @@ struct HomeView: View { if proxy.safeAreaInsets.bottom > 0 { Rectangle() - .foregroundColor(Color(hex: "111111")) + .foregroundColor(Color.gray11) .frame(width: proxy.size.width, height: 15.3) } } @@ -174,6 +227,10 @@ struct HomeView: View { AppState.shared.eventPopup = nil } } + + if isShowPlayer { + ContentPlayerView(isShowing: $isShowPlayer, playlist: []) + } } .edgesIgnoringSafeArea(.bottom) .valueChanged(value: appState.pushRoomId) { value in diff --git a/SodaLive/Sources/Settings/SettingsView.swift b/SodaLive/Sources/Settings/SettingsView.swift index 0d52d49..2823196 100644 --- a/SodaLive/Sources/Settings/SettingsView.swift +++ b/SodaLive/Sources/Settings/SettingsView.swift @@ -235,6 +235,8 @@ struct SettingsView: View { desc: "로그아웃 하시겠어요?", confirmButtonTitle: "확인", confirmButtonAction: { + ContentPlayManager.shared.stopAudio() + ContentPlayerPlayManager.shared.resetPlayer() viewModel.logout { self.isShowLogoutDialog = false AppState.shared.setAppStep(step: .main) @@ -254,15 +256,17 @@ struct SettingsView: View { desc: "모든 기기에서 로그아웃 하시겠어요?", confirmButtonTitle: "확인", confirmButtonAction: { + ContentPlayManager.shared.stopAudio() + ContentPlayerPlayManager.shared.resetPlayer() viewModel.logoutAllDevice { - self.isShowLogoutDialog = false + self.isShowLogoutAllDeviceDialog = false AppState.shared.setAppStep(step: .main) UserDefaults.reset() } }, cancelButtonTitle: "취소", cancelButtonAction: { - self.isShowLogoutDialog = false + self.isShowLogoutAllDeviceDialog = false } ) }