재생목록 플레이어 추가
This commit is contained in:
		
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/ic_player_pause.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_player_pause.imageset/ic_player_pause.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 542 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/ic_player_play.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_player_play.imageset/ic_player_play.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.1 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/ic_playlist.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_playlist.imageset/ic_playlist.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 655 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/ic_skip_back.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_skip_back.imageset/ic_skip_back.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 823 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/ic_skip_forward.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_skip_forward.imageset/ic_skip_forward.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 822 B | 
| @@ -18,6 +18,7 @@ class AppState: ObservableObject { | ||||
|         didSet { | ||||
|             if isShowPlayer { | ||||
|                 ContentPlayManager.shared.stopAudio() | ||||
|                 ContentPlayerPlayManager.shared.resetPlayer() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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<ContentApi>() | ||||
|      | ||||
|     func generateUrl(contentId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.generateUrl(contentId: contentId)) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										326
									
								
								SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										326
									
								
								SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AnyCancellable>() | ||||
|      | ||||
|     @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<GenerateUrlResponse>.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<CMTime, Never> { | ||||
|         let subject = PassthroughSubject<CMTime, Never>() | ||||
|          | ||||
|         // 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() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										189
									
								
								SodaLive/Sources/Content/Player/ContentPlayerView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								SodaLive/Sources/Content/Player/ContentPlayerView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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..<playerManager.playlist.count, id: \.self) { | ||||
|                                 PlaylistContentItemView(item: playerManager.playlist[$0]) | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } else { | ||||
|                     KFImage(URL(string: playerManager.coverImageUrl)) | ||||
|                         .cancelOnDisappear(true) | ||||
|                         .downsampling(size: CGSize(width: 240, height: 240)) | ||||
|                         .resizable() | ||||
|                         .scaledToFill() | ||||
|                         .frame(width: 240, height: 240) | ||||
|                         .cornerRadius(8) | ||||
|                 } | ||||
|                  | ||||
|                 Spacer() | ||||
|                  | ||||
|                 if playerManager.duration > 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" | ||||
|             ) | ||||
|         ] | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										10
									
								
								SodaLive/Sources/Content/Player/GenerateUrlResponse.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								SodaLive/Sources/Content/Player/GenerateUrlResponse.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| // | ||||
| //  GenerateUrlResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 12/17/24. | ||||
| // | ||||
|  | ||||
| struct GenerateUrlResponse: Decodable { | ||||
|     let contentUrl: String | ||||
| } | ||||
| @@ -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) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|                     } | ||||
|                 ) | ||||
|             } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung