// // LiveRoomViewV2.swift // SodaLive // // Created by klaus on 2024/01/17. // import SwiftUI import Kingfisher import SDWebImageSwiftUI struct LiveRoomViewV2: View { @StateObject var keyboardHandler = KeyboardHandler() @StateObject var viewModel = LiveRoomViewModel() @State private var textHeight: CGFloat = .zero var body: some View { ZStack { Color.black.edgesIgnoringSafeArea(.all) VStack(spacing: 0) { if let liveRoomInfo = viewModel.liveRoomInfo { if liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { LiveRoomInfoHostView( title: liveRoomInfo.title, totalDonationCan: viewModel.totalDonationCan, participantsCount: liveRoomInfo.participantsCount, isOnBg: viewModel.isBgOn, isOnNotice: viewModel.isShowNotice, isOnMenuPan: viewModel.isShowMenuPan, isShowMenuPanButton: !liveRoomInfo.menuPan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, creatorId: liveRoomInfo.creatorId, creatorNickname: liveRoomInfo.creatorNickname, creatorProfileUrl: liveRoomInfo.creatorProfileUrl, speakerList: liveRoomInfo.speakerList, muteSpeakerList: viewModel.muteSpeakers, activeSpeakerList: viewModel.activeSpeakers, isAdult: liveRoomInfo.isAdult, onClickQuit: { viewModel.isShowLiveEndPopup = true }, onClickToggleBg: { viewModel.isBgOn.toggle() }, onClickShare: { viewModel.shareRoom() }, onClickEdit: { viewModel.isShowEditRoomInfoDialog = true }, onClickProfile: { if $0 != UserDefaults.int(forKey: .userId) { viewModel.getUserProfile(userId: $0) } }, onClickNotice: { viewModel.isShowNotice.toggle() }, onClickMenuPan: { viewModel.isShowMenuPan.toggle() }, onClickTotalDonation: { viewModel.isShowDonationRankingPopup = true }, onClickParticipants: { viewModel.isShowProfileList = true } ) } else { LiveRoomInfoGuestView( title: liveRoomInfo.title, totalDonationCan: viewModel.totalDonationCan, isOnBg: viewModel.isBgOn, isOnNotice: viewModel.isShowNotice, isOnMenuPan: viewModel.isShowMenuPan, isShowMenuPanButton: !liveRoomInfo.menuPan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, creatorId: liveRoomInfo.creatorId, creatorNickname: liveRoomInfo.creatorNickname, creatorProfileUrl: liveRoomInfo.creatorProfileUrl, speakerList: liveRoomInfo.speakerList, muteSpeakerList: viewModel.muteSpeakers, activeSpeakerList: viewModel.activeSpeakers, isFollowing: liveRoomInfo.isFollowing, isAdult: liveRoomInfo.isAdult, onClickQuit: { viewModel.isShowQuitPopup = true }, onClickToggleBg: { viewModel.isBgOn.toggle() }, onClickShare: { viewModel.shareRoom() }, onClickFollow: { if $0 { viewModel.creatorUnFollow() } else { viewModel.creatorFollow() } }, onClickProfile: { if $0 != UserDefaults.int(forKey: .userId) { viewModel.getUserProfile(userId: $0) } }, onClickNotice: { viewModel.isShowNotice.toggle() }, onClickMenuPan: { viewModel.isShowMenuPan.toggle() }, onClickTotalDonation: { viewModel.isShowDonationRankingPopup = true }, onClickChangeListener: { viewModel.setListener() } ) } ZStack(alignment: .topLeading) { Rectangle() .foregroundColor(.gray22) .frame(height: 16) .frame(maxWidth: .infinity) ScrollViewReader { proxy in ZStack(alignment: .bottom) { ZStack { if viewModel.isBgOn { KFImage(URL(string: liveRoomInfo.coverImageUrl)) .resizable() .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) } Rectangle() .foregroundColor(.black.opacity(0.25)) .frame(maxWidth: .infinity) ScrollView(.vertical, showsIndicators: false) { scrollObservableView LiveRoomChatView(messages: viewModel.messages) { if $0 != UserDefaults.int(forKey: .userId) { viewModel.getUserProfile(userId: $0) } } .frame(width: screenSize().width) .rotationEffect(Angle(degrees: 180)) .valueChanged(value: viewModel.messageChangeFlag) { _ in if viewModel.offset - viewModel.originOffset > (56.7 * 2) { viewModel.isShowingNewChat = true } } } .rotationEffect(Angle(degrees: 180)) .onTapGesture { hideKeyboard() } .onPreferenceChange(ScrollOffsetKey.self) { viewModel.setOffset($0) } .padding(.bottom, 70) } .padding(.top, 16) VStack(alignment: .trailing, spacing: 0) { Image(viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .onTapGesture { viewModel.toggleSpeakerMute() } .padding(.top, 26.7) .padding(.trailing, 13.3) Spacer() VStack(spacing: 13.3) { if liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { Image("ic_roulette_settings") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .onTapGesture { viewModel.isShowRouletteSettings = true } } else if liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) && viewModel.isActiveRoulette { Image("ic_roulette") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .onTapGesture { viewModel.showRoulette() } } if viewModel.role == .SPEAKER { Image(viewModel.isMute ? "ic_mic_off" : "ic_mic_on") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .onTapGesture { viewModel.toggleMute() } } if liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) && UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue { Image("ic_donation_message_list") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .onTapGesture { viewModel.isShowDonationMessagePopup = true } } else { Image("ic_donation") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .onTapGesture { viewModel.isShowDonationPopup = true } } } .padding(.trailing, 13.3) LiveRoomInputChatView { viewModel.sendMessage(chatMessage: $0) { viewModel.isShowingNewChat = false proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) } return true } .padding(.bottom, 10) } if viewModel.isShowingNewChat { LiveRoomNewChatView{ viewModel.isShowingNewChat = false proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) }.padding(.bottom, 70) } if viewModel.signatureImageUrl.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 { HStack { Spacer() VStack(alignment: .trailing, spacing: 0) { Spacer() AnimatedImage(url: URL(string: viewModel.signatureImageUrl)) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 200) Spacer() } } } } } if viewModel.isShowNotice { VStack(alignment: .leading, spacing: 0) { Image("ic_notice_triangle") .padding(.leading, 13.3) VStack(alignment: .leading, spacing: 8) { Text("[방송공지]") .font(.custom(Font.bold.rawValue, size: 11.3)) .foregroundColor(.white) DetectableTextView(text: liveRoomInfo.notice) .frame( width: 280, height: textHeight > 450 ? 450 : textHeight ) .onAppear { self.textHeight = self.estimatedHeight( for: liveRoomInfo.notice, width: 280 ) } .onChange(of: liveRoomInfo.notice) { newText in self.textHeight = self.estimatedHeight( for: newText, width: 280 ) } } .padding(8) .background(Color.gray33) .padding(.horizontal, 13.3) .padding(.bottom, 120) } } if viewModel.isShowMenuPan { VStack(alignment: .leading, spacing: 0) { Image("ic_notice_triangle") .padding(.leading, 60) VStack(alignment: .leading, spacing: 8) { Text("[메뉴판]") .font(.custom(Font.bold.rawValue, size: 11.3)) .foregroundColor(.white) Text(liveRoomInfo.menuPan) .font(.custom(Font.light.rawValue, size: 11.3)) .foregroundColor(.white) .lineSpacing(4) } .padding(8) .background(Color.gray33) .padding(.horizontal, 60) .padding(.bottom, 120) } } } } } .popup(isPresented: $viewModel.isShowErrorPopup, type: .toast, position: .top, autohideIn: 1.3) { GeometryReader { geo in HStack { Spacer() Text(viewModel.errorMessage) .padding(.vertical, 13.3) .frame(width: geo.size.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 viewModel.liveRoomInfo == nil { viewModel.quitRoom() } } } } .cornerRadius(16.7, corners: [.topLeft, .topRight]) .offset(y: -(keyboardHandler.keyboardHeight > 0 ? keyboardHandler.keyboardHeight : 0)) .onAppear { UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) viewModel.getMemberCan() viewModel.initAgoraEngine() viewModel.getRoomInfo() NotificationCenter.default.addObserver( forName: UIApplication.willTerminateNotification, object: nil, queue: .main) { _ in viewModel.quitRoom() sleep(3) } } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false NotificationCenter.default.removeObserver(self) } ZStack { if viewModel.isShowProfilePopup, let liveRoomInfo = viewModel.liveRoomInfo, let selectedProfile = viewModel.selectedProfile { LiveRoomProfileDialog( isShowing: $viewModel.isShowProfilePopup, profileInfo: selectedProfile, creatorId: liveRoomInfo.creatorId, isSpeaker: viewModel.role == .SPEAKER, onClickInviteSpeaker: { inviteSpeaker(peerId: $0) }, onClickChangeListener: { if $0 == UserDefaults.int(forKey: .userId) { viewModel.setListener() return } viewModel.changeListener(peerId: $0) } ) } if viewModel.isShowDonationPopup { LiveRoomDonationDialogView(isShowing: $viewModel.isShowDonationPopup, isAudioContentDonation: false) { can, message in viewModel.donation(can: can, message: message) } } if viewModel.isShowQuitPopup { SodaDialog( title: "라이브 나가기", desc: "라이브에서 나가시겠습니까?", confirmButtonTitle: "예", confirmButtonAction: { viewModel.isShowQuitPopup = false viewModel.quitRoom() }, cancelButtonTitle: "아니오", cancelButtonAction: { viewModel.isShowQuitPopup = false } ) } if viewModel.isShowLiveEndPopup { SodaDialog( title: "라이브 종료", desc: "라이브를 종료하시겠습니까?\n" + "라이브를 종료하면 대화내용은\n" + "저장되지 않고 사라집니다.\n" + "참여자들 또한 라이브가 종료되어\n" + "강제퇴장 됩니다.", confirmButtonTitle: "예", confirmButtonAction: { viewModel.isShowLiveEndPopup = false viewModel.quitRoom() }, cancelButtonTitle: "아니오", cancelButtonAction: { viewModel.isShowLiveEndPopup = false } ) } } ZStack { if viewModel.isShowProfileList, let liveRoomInfo = viewModel.liveRoomInfo { LiveRoomProfilesDialogView( isShowing: $viewModel.isShowProfileList, viewModel: viewModel, roomInfo: liveRoomInfo, registerNotification: { viewModel.creatorFollow() }, unRegisterNotification: { viewModel.creatorUnFollow() }, onClickProfile: { if $0 != UserDefaults.int(forKey: .userId) { viewModel.getUserProfile(userId: $0) } }, onClickNoChatting: { userId, nickname, profileUrl in viewModel.noChattingUserId = userId viewModel.noChattingUserNickname = nickname viewModel.noChattingUserProfileUrl = profileUrl viewModel.isShowNoChattingConfirm = true } ) } if viewModel.isShowUserProfilePopup, let userProfile = viewModel.userProfile { Color.black.opacity(0.7) .edgesIgnoringSafeArea(.all) LiveRoomUserProfileDialogView( isShowing: $viewModel.isShowUserProfilePopup, viewModel: viewModel, userProfile: userProfile, onClickSetManager: { viewModel.setManagerMessageToPeer(userId: $0) viewModel.setManager(userId: $0) }, onClickReleaseManager: { viewModel.changeListener(peerId: $0, isFromManager: true) }, onClickFollow: { viewModel.creatorFollow(creatorId: $0, isGetUserProfile: true) }, onClickUnFollow: { viewModel.creatorUnFollow(creatorId: $0, isGetUserProfile: true) }, onClickInviteSpeaker: { inviteSpeaker(peerId: $0) }, onClickChangeListener: { viewModel.changeListener(peerId: $0) }, onClickMenu: { userId, userNickname, isBlocked in viewModel.reportUserId = userId viewModel.reportUserNickname = userNickname viewModel.reportUserIsBlocked = isBlocked viewModel.isShowReportMenu = true }, onClickNoChatting: { userId, nickname, profileUrl in viewModel.noChattingUserId = userId viewModel.noChattingUserNickname = nickname viewModel.noChattingUserProfileUrl = profileUrl viewModel.isShowNoChattingConfirm = true } ) .padding(20) .popup(isPresented: $viewModel.isShowReportPopup, type: .toast, position: .top, autohideIn: 1.3) { GeometryReader { geo in HStack { Spacer() Text(viewModel.reportMessage) .padding(.vertical, 13.3) .frame(width: geo.size.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() } } } } if viewModel.isShowReportMenu { VStack(spacing: 0) { ProfileReportMenuView( isShowing: $viewModel.isShowReportMenu, isBlockedUser: viewModel.reportUserIsBlocked, userBlockAction: { viewModel.isShowUesrBlockConfirm = true }, userUnBlockAction: { viewModel.userUnBlock() }, userReportAction: { viewModel.isShowUesrReportView = true }, profileReportAction: { viewModel.isShowProfileReportConfirm = true } ) Rectangle() .foregroundColor(Color(hex: "222222")) .frame(width: screenSize().width, height: 15.3) } .ignoresSafeArea() } if viewModel.isShowUesrBlockConfirm { UserBlockConfirmDialogView( isShowing: $viewModel.isShowUesrBlockConfirm, nickname: viewModel.reportUserNickname, confirmAction: { viewModel.userBlock { userId in viewModel.kickOutId = userId viewModel.kickOut() } } ) } if viewModel.isShowUesrReportView { UserReportDialogView( isShowing: $viewModel.isShowUesrReportView, confirmAction: { reason in viewModel.report(type: .USER, reason: reason) } ) } if viewModel.isShowProfileReportConfirm { ProfileReportDialogView( isShowing: $viewModel.isShowProfileReportConfirm, confirmAction: { viewModel.report(type: .PROFILE) } ) } if viewModel.isShowNoChattingConfirm && viewModel.noChattingUserId > 0 { LiveRoomNoChattingDialogView( nickname: viewModel.noChattingUserNickname, profileUrl: viewModel.noChattingUserProfileUrl, confirmAction: { viewModel.isShowNoChattingConfirm = false viewModel.setNoChatting() }, cancelAction: { viewModel.noChattingUserId = 0 viewModel.noChattingUserNickname = "" viewModel.noChattingUserProfileUrl = "" viewModel.isShowNoChattingConfirm = false } ) } if viewModel.isShowPopup { LiveRoomDialogView( content: viewModel.popupContent, cancelTitle: viewModel.popupCancelTitle, cancelAction: viewModel.popupCancelAction, confirmTitle: viewModel.popupConfirmTitle, confirmAction: viewModel.popupConfirmAction ).onAppear { if viewModel.popupConfirmTitle == nil && viewModel.popupConfirmAction == nil { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { viewModel.isShowPopup = false viewModel.popupCancelTitle = nil viewModel.popupCancelAction = nil viewModel.popupConfirmTitle = nil viewModel.popupConfirmAction = nil } } } } } if viewModel.isShowRouletteSettings { RouletteSettingsView(isShowing: $viewModel.isShowRouletteSettings) { isActiveRoulette, message in self.viewModel.setActiveRoulette( isActiveRoulette: isActiveRoulette, message: message ) } } if let preview = viewModel.roulettePreview, viewModel.isShowRoulettePreview { RoulettePreviewDialog( isShowing: $viewModel.isShowRoulettePreview, title: nil, onClickSpin: { viewModel.spinRoulette() }, preview: preview ) } if viewModel.isShowRoulette { RouletteViewDialog(isShowing: $viewModel.isShowRoulette, options: viewModel.rouletteItems, selectedOption: viewModel.rouletteSelectedItem) { viewModel.sendRouletteDonation() } } if viewModel.isLoading && viewModel.liveRoomInfo == nil { LoadingView() } } .ignoresSafeArea(.keyboard) .edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init()) .sheet( isPresented: $viewModel.isShowShareView, onDismiss: { viewModel.shareMessage = "" }, content: { ActivityViewController(activityItems: [viewModel.shareMessage]) } ) .sheet(isPresented: $viewModel.isShowPhotoPicker) { ImagePicker( isShowing: $viewModel.isShowPhotoPicker, selectedImage: $viewModel.coverImage, sourceType: .photoLibrary ) } .sheet(isPresented: $viewModel.isShowEditRoomInfoDialog) { if let liveRoomInfo = viewModel.liveRoomInfo { LiveRoomInfoEditDialog( isShowing: $viewModel.isShowEditRoomInfoDialog, isShowPhotoPicker: $viewModel.isShowPhotoPicker, viewModel: viewModel, isLoading: viewModel.isLoading, currentTitle: liveRoomInfo.title, currentNotice: liveRoomInfo.notice, coverImageUrl: liveRoomInfo.coverImageUrl, coverImage: viewModel.coverImage ) { newTitle, newNotice in self.viewModel.editLiveRoomInfo( title: newTitle, notice: newNotice ) } } else { EmptyView() .onAppear { viewModel.isShowEditRoomInfoDialog = false } } } .sheet(isPresented: $viewModel.isShowDonationRankingPopup) { LiveRoomDonationRankingDialog(isShowing: $viewModel.isShowDonationRankingPopup) } .sheet(isPresented: $viewModel.isShowDonationMessagePopup) { LiveRoomDonationMessageDialog(isShowing: $viewModel.isShowDonationMessagePopup) } } private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat { let textView = UITextView(frame: CGRect(x: 0, y: 0, width: width, height: .greatestFiniteMagnitude)) textView.font = UIFont.systemFont(ofSize: 11.3) textView.text = text return textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height } private func inviteSpeaker(peerId: Int) { if viewModel.liveRoomInfo!.speakerList.count <= 4 { viewModel.inviteSpeaker(peerId: peerId) self.viewModel.popupContent = "스피커 요청을 보냈습니다.\n잠시만 기다려 주세요." self.viewModel.isShowPopup = true } else { viewModel.popupContent = "스피커 정원을 초과했습니다." viewModel.isShowPopup = true } } private var scrollObservableView: some View { GeometryReader { proxy in let offsetY = proxy.frame(in: .global).origin.y Color.clear .preference( key: ScrollOffsetKey.self, value: offsetY ) .onAppear { viewModel.setOriginOffset(offsetY) } } .frame(height: 0) } struct ScrollOffsetKey: PreferenceKey { static var defaultValue: CGFloat = .zero static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value += nextValue() } } } struct LiveRoomViewV2_Previews: PreviewProvider { static var previews: some View { LiveRoomViewV2() } }