1330 lines
64 KiB
Swift
1330 lines
64 KiB
Swift
//
|
|
// LiveRoomViewV2.swift
|
|
// SodaLive
|
|
//
|
|
// Created by klaus on 2024/01/17.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Kingfisher
|
|
import SDWebImageSwiftUI
|
|
import Combine
|
|
|
|
struct LiveRoomViewV2: View {
|
|
|
|
@StateObject var keyboardHandler = KeyboardHandler()
|
|
@StateObject var viewModel = LiveRoomViewModel()
|
|
|
|
@State private var textHeight: CGFloat = .zero
|
|
@State private var menuTextHeight: CGFloat = .zero
|
|
@State private var selectedPickedImage: UIImage?
|
|
@State private var cropSourceImage: UIImage?
|
|
@State private var isShowImageCropper = false
|
|
@State private var isImageLoading = false
|
|
|
|
// 롱프레스 하트 물 채우기 상태
|
|
@State private var isLongPressingHeart: Bool = false
|
|
@State private var longPressStartAt: Date? = nil
|
|
@State private var showWaterHeart: Bool = false
|
|
@State private var waterProgress: CGFloat = 0
|
|
@State private var wavePhase: CGFloat = 0
|
|
@State private var isShowFollowNotifyDialog: Bool = false
|
|
@State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil
|
|
@State private var selectedChatForDelete: LiveRoomNormalChat? = nil
|
|
@State private var isShowChatDeleteDialog: Bool = false
|
|
// 화면 캡쳐/녹화 보호 상태 및 강제 음소거 복원 여부
|
|
@State private var isScreenCaptureProtected: Bool = UIScreen.main.isCaptured
|
|
@State private var shouldRestoreSpeakerMuteAfterCapture: Bool = false
|
|
@State private var shouldRestoreMicMuteAfterCapture: Bool = false
|
|
let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect()
|
|
|
|
private var appliedKeyboardHeight: CGFloat {
|
|
guard !viewModel.isChatFrozenForCurrentUser else {
|
|
return 0
|
|
}
|
|
|
|
return max(keyboardHandler.keyboardHeight, 0)
|
|
}
|
|
|
|
private var isChatInputDisabled: Bool {
|
|
viewModel.isChatFrozenForCurrentUser || viewModel.isNoChatting
|
|
}
|
|
|
|
private var chatInputBlockedMessage: String {
|
|
if viewModel.isChatFrozenForCurrentUser {
|
|
return I18n.LiveRoom.chatFreezeBlockedMessage
|
|
}
|
|
|
|
if viewModel.isNoChatting {
|
|
return "\(viewModel.remainingNoChattingTime)초 동안 채팅하실 수 없습니다"
|
|
}
|
|
|
|
return I18n.LiveRoom.chatFreezeBlockedMessage
|
|
}
|
|
|
|
var body: some View {
|
|
ScreenCaptureSecureContainer {
|
|
ZStack {
|
|
Color.black.edgesIgnoringSafeArea(.all)
|
|
|
|
VStack(spacing: 0) {
|
|
if let liveRoomInfo = viewModel.liveRoomInfo {
|
|
if liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) {
|
|
LiveRoomInfoHostView(
|
|
title: liveRoomInfo.title,
|
|
totalHeart: viewModel.totalHeartCount,
|
|
totalDonationCan: viewModel.totalDonationCan,
|
|
participantsCount: liveRoomInfo.participantsCount,
|
|
isOnBg: viewModel.isBgOn,
|
|
isOnNotice: viewModel.isShowNotice,
|
|
isOnMenuPan: viewModel.isShowMenuPan,
|
|
isOnSignature: viewModel.isSignatureOn,
|
|
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()
|
|
},
|
|
onClickTotalHeart: {
|
|
viewModel.isShowHeartRankingPopup = true
|
|
},
|
|
onClickTotalDonation: {
|
|
viewModel.isShowDonationRankingPopup = true
|
|
},
|
|
onClickParticipants: {
|
|
viewModel.isShowProfileList = true
|
|
},
|
|
onClickToggleSignature: {
|
|
viewModel.isSignatureOn.toggle()
|
|
}
|
|
)
|
|
} else {
|
|
LiveRoomInfoGuestView(
|
|
title: liveRoomInfo.title,
|
|
totalHeart: viewModel.totalHeartCount,
|
|
totalDonationCan: viewModel.totalDonationCan,
|
|
isOnBg: viewModel.isBgOn,
|
|
isOnNotice: viewModel.isShowNotice,
|
|
isOnMenuPan: viewModel.isShowMenuPan,
|
|
isOnSignature: viewModel.isSignatureOn,
|
|
isOnV2VCaption: viewModel.isV2VCaptionOn,
|
|
isShowMenuPanButton: !liveRoomInfo.menuPan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
isShowV2VCaptionButton: viewModel.isV2VAvailable,
|
|
creatorId: liveRoomInfo.creatorId,
|
|
creatorNickname: liveRoomInfo.creatorNickname,
|
|
creatorProfileUrl: liveRoomInfo.creatorProfileUrl,
|
|
followButtonType: guestFollowButtonType(liveRoomInfo: liveRoomInfo),
|
|
speakerList: liveRoomInfo.speakerList,
|
|
muteSpeakerList: viewModel.muteSpeakers,
|
|
activeSpeakerList: viewModel.activeSpeakers,
|
|
isAdult: liveRoomInfo.isAdult,
|
|
onClickQuit: {
|
|
viewModel.isShowQuitPopup = true
|
|
},
|
|
onClickToggleBg: {
|
|
viewModel.isBgOn.toggle()
|
|
},
|
|
onClickShare: {
|
|
viewModel.shareRoom()
|
|
},
|
|
onClickProfile: {
|
|
if $0 != UserDefaults.int(forKey: .userId) {
|
|
viewModel.getUserProfile(userId: $0)
|
|
}
|
|
},
|
|
onClickNotice: {
|
|
viewModel.isShowNotice.toggle()
|
|
},
|
|
onClickMenuPan: {
|
|
viewModel.isShowMenuPan.toggle()
|
|
},
|
|
onClickTotalHeart: {
|
|
viewModel.isShowHeartRankingPopup = true
|
|
},
|
|
onClickTotalDonation: {
|
|
viewModel.isShowDonationRankingPopup = true
|
|
},
|
|
onClickFollow: {
|
|
let buttonType = guestFollowButtonType(liveRoomInfo: liveRoomInfo)
|
|
|
|
if buttonType == .follow {
|
|
guestFollowButtonTypeOverride = .following
|
|
viewModel.creatorFollow(follow: true, notify: true)
|
|
} else {
|
|
isShowFollowNotifyDialog = true
|
|
}
|
|
},
|
|
onClickChangeListener: {
|
|
viewModel.setListener()
|
|
},
|
|
onClickToggleV2VCaption: {
|
|
viewModel.toggleV2VCaption()
|
|
},
|
|
onClickToggleSignature: {
|
|
viewModel.isSignatureOn.toggle()
|
|
}
|
|
)
|
|
}
|
|
|
|
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))
|
|
.cancelOnDisappear(true)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
Rectangle()
|
|
.foregroundColor(.black.opacity(0.25))
|
|
.frame(maxWidth: .infinity)
|
|
|
|
ScrollView(.vertical, showsIndicators: false) {
|
|
scrollObservableView
|
|
|
|
if !viewModel.changeIsAdult || UserDefaults.bool(forKey: .auth) {
|
|
LiveRoomChatView(
|
|
messages: viewModel.messages,
|
|
isCreator: liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId),
|
|
getUserProfile: {
|
|
if $0 != UserDefaults.int(forKey: .userId) {
|
|
viewModel.getUserProfile(userId: $0)
|
|
}
|
|
},
|
|
onLongPressChat: { chat in
|
|
selectedChatForDelete = chat
|
|
isShowChatDeleteDialog = true
|
|
}
|
|
)
|
|
.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, v2vCaptionBottomInset)
|
|
}
|
|
.padding(.top, 16)
|
|
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
Spacer()
|
|
|
|
VStack(spacing: 13.3) {
|
|
LiveRoomRightBottomButton(
|
|
imageName: viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on",
|
|
onClick: { viewModel.toggleSpeakerMute() }
|
|
)
|
|
}
|
|
.padding(.bottom, 40)
|
|
.padding(.trailing, 13.3)
|
|
|
|
ZStack(alignment: .bottom) {
|
|
VStack(spacing: 13.3) {
|
|
if viewModel.role == .SPEAKER {
|
|
LiveRoomRightBottomButton(
|
|
imageName: viewModel.isMute ? "ic_mic_off" : "ic_mic_on",
|
|
onClick: { viewModel.toggleMute() }
|
|
)
|
|
}
|
|
|
|
if liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) {
|
|
LiveRoomRightBottomButton(
|
|
imageName: "ic_ice",
|
|
onClick: {
|
|
viewModel.setChatFreeze(isChatFrozen: !viewModel.isChatFrozen)
|
|
},
|
|
backgroundColor: viewModel.isChatFrozen
|
|
? Color(hex: "3bb9f1").opacity(0.5)
|
|
: nil
|
|
)
|
|
}
|
|
|
|
LiveRoomRightBottomButton(
|
|
imageName: "ic_donation_message_list",
|
|
onClick: {
|
|
DispatchQueue.main.async {
|
|
hideKeyboard()
|
|
viewModel.isShowDonationMessagePopup = true
|
|
}
|
|
}
|
|
)
|
|
|
|
if liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) {
|
|
LiveRoomRightBottomButton(
|
|
imageName: "ic_roulette_settings",
|
|
onClick: {
|
|
DispatchQueue.main.async {
|
|
hideKeyboard()
|
|
viewModel.isShowRouletteSettings = true
|
|
}
|
|
}
|
|
)
|
|
} else {
|
|
if viewModel.isActiveRoulette {
|
|
LiveRoomRightBottomButton(
|
|
imageName: "ic_roulette",
|
|
onClick: {
|
|
DispatchQueue.main.async {
|
|
hideKeyboard()
|
|
viewModel.showRoulette()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
if liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) {
|
|
LiveRoomRightBottomButton(
|
|
imageName: "ic_donation",
|
|
onClick: {
|
|
DispatchQueue.main.async {
|
|
hideKeyboard()
|
|
viewModel.isShowDonationPopup = true
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
if liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) {
|
|
LiveRoomRightBottomButton(
|
|
imageName: "ic_heart_pink",
|
|
onClick: { viewModel.likeHeart() },
|
|
onLongPress: {
|
|
if !viewModel.isAvailableLikeHeart {
|
|
viewModel.isShowNoticeLikeHeart = true
|
|
return
|
|
}
|
|
viewModel.likeHeart(messageType: .BIG_HEART_DONATION, heartCount: 100)
|
|
}
|
|
)
|
|
.onLongPressGesture(
|
|
minimumDuration: 2.0,
|
|
maximumDistance: 50,
|
|
pressing: { pressing in
|
|
if pressing {
|
|
// 좋아해요 사용 가능 여부 체크: 불가 시 즉시 중단하고 안내 노출
|
|
if !viewModel.isAvailableLikeHeart {
|
|
isLongPressingHeart = false
|
|
showWaterHeart = false
|
|
waterProgress = 0
|
|
longPressStartAt = nil
|
|
viewModel.isShowNoticeLikeHeart = true
|
|
return
|
|
}
|
|
if !isLongPressingHeart {
|
|
isLongPressingHeart = true
|
|
longPressStartAt = Date()
|
|
// 초기 상태
|
|
waterProgress = 0
|
|
wavePhase = 0
|
|
}
|
|
} else {
|
|
isLongPressingHeart = false
|
|
showWaterHeart = false
|
|
waterProgress = 0
|
|
longPressStartAt = nil
|
|
}
|
|
},
|
|
perform: {
|
|
// perform는 내부 컴포넌트(onLongPress)에서 처리하므로 여기서는 중복 방지용으로 비워둠
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
ZStack {
|
|
ForEach(viewModel.hearts) { heart in
|
|
LiveRoomHeartView(heart: heart)
|
|
.offset(x: heart.offsetX, y: heart.offsetY)
|
|
.opacity(heart.opacity)
|
|
}
|
|
}.padding(.bottom, 40)
|
|
}
|
|
.padding(.trailing, 13.3)
|
|
|
|
if isV2VCaptionVisible {
|
|
Text(viewModel.v2vCaptionText)
|
|
.appFont(size: 12, weight: .medium)
|
|
.foregroundColor(.white)
|
|
.lineLimit(2)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.background(Color.black.opacity(0.75))
|
|
.cornerRadius(10)
|
|
.padding(.horizontal, 13.3)
|
|
}
|
|
|
|
LiveRoomInputChatView(
|
|
isInputDisabled: isChatInputDisabled,
|
|
sendMessage: {
|
|
viewModel.sendMessage(chatMessage: $0) {
|
|
viewModel.isShowingNewChat = false
|
|
proxy.scrollTo(viewModel.messages.count - 1, anchor: .center)
|
|
}
|
|
|
|
return true
|
|
},
|
|
onDisabledInputTap: {
|
|
viewModel.errorMessage = chatInputBlockedMessage
|
|
viewModel.isShowErrorPopup = true
|
|
}
|
|
)
|
|
.padding(.top, isV2VCaptionVisible ? -13.3 : 0)
|
|
.padding(.bottom, 10)
|
|
}
|
|
|
|
if viewModel.isShowingNewChat {
|
|
LiveRoomNewChatView{
|
|
viewModel.isShowingNewChat = false
|
|
proxy.scrollTo(viewModel.messages.count - 1, anchor: .center)
|
|
}.padding(.bottom, v2vCaptionBottomInset)
|
|
}
|
|
|
|
if viewModel.isSignatureOn && viewModel.signatureImageUrl.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 {
|
|
VStack {
|
|
Spacer()
|
|
|
|
AnimatedImage(url: URL(string: viewModel.signatureImageUrl))
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: screenSize().width - 64)
|
|
|
|
Spacer()
|
|
Spacer()
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
if let signature = viewModel.signature, viewModel.isSignatureOn {
|
|
VStack {
|
|
Spacer()
|
|
|
|
AnimatedImage(url: URL(string: signature.imageUrl))
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: screenSize().width - 64)
|
|
|
|
Spacer()
|
|
Spacer()
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let heartNickname = viewModel.heartNickname {
|
|
LiveRoomHeartDonationChatItemView(nickname: heartNickname)
|
|
.padding(.top, 16)
|
|
}
|
|
|
|
if viewModel.isShowNotice {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Image("ic_notice_triangle")
|
|
.padding(.leading, 13.3)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("[방송공지]")
|
|
.appFont(size: 11.3, weight: .bold)
|
|
.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")
|
|
|
|
ScrollView(.vertical) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("[메뉴판]")
|
|
.appFont(size: 11.3, weight: .bold)
|
|
.foregroundColor(.white)
|
|
|
|
Text(liveRoomInfo.menuPan)
|
|
.appFont(size: 11.3, weight: .light)
|
|
.foregroundColor(.white)
|
|
.lineSpacing(4)
|
|
}
|
|
.background(GeometryReader { geometry in
|
|
Color.clear.preference(key: TextViewHeightKey.self, value: geometry.size.height)
|
|
})
|
|
.padding(8)
|
|
}
|
|
.background(Color.gray33)
|
|
.frame(height: menuTextHeight > 500 ? 500 : menuTextHeight)
|
|
.onPreferenceChange(TextViewHeightKey.self) { value in
|
|
menuTextHeight = value + 15
|
|
}
|
|
}
|
|
.frame(maxWidth: 350, alignment: .leading)
|
|
.padding(.leading, 60)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sodaToast(isPresented: $viewModel.isShowErrorPopup, message: viewModel.errorMessage, autohideIn: 1.3)
|
|
.cornerRadius(16.7, corners: [.topLeft, .topRight])
|
|
.offset(y: -appliedKeyboardHeight)
|
|
.onAppear {
|
|
UIApplication.shared.isIdleTimerDisabled = true
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
viewModel.initAgoraEngine()
|
|
// 진입 시 현재 캡쳐 상태를 즉시 동기화해 첫 프레임부터 보호 상태를 반영
|
|
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
|
|
|
|
viewModel.getMemberCan()
|
|
viewModel.getRoomInfo()
|
|
viewModel.getBlockedMemberIdList()
|
|
|
|
NotificationCenter.default.addObserver(
|
|
forName: UIApplication.willTerminateNotification,
|
|
object: nil,
|
|
queue: .main) { _ in
|
|
viewModel.quitRoom()
|
|
sleep(3)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
NotificationCenter.default.removeObserver(self)
|
|
// 화면 이탈 시 캡쳐로 인해 강제 변경한 음소거 상태를 원복
|
|
releaseForcedCaptureMute()
|
|
viewModel.stopV2VTranslationIfJoined()
|
|
viewModel.stopPeriodicPlaybackValidation()
|
|
}
|
|
|
|
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, isSecret in
|
|
viewModel.donation(can: can, message: message, isSecret: isSecret)
|
|
}
|
|
}
|
|
|
|
if viewModel.changeIsAdult && !UserDefaults.bool(forKey: .auth) {
|
|
SodaDialog(
|
|
title: I18n.Common.alertTitle,
|
|
desc: I18n.LiveRoom.ageRestrictionDesc,
|
|
confirmButtonTitle: I18n.Common.confirm,
|
|
confirmButtonAction: {
|
|
viewModel.quitRoom()
|
|
}
|
|
)
|
|
}
|
|
|
|
if viewModel.isShowNoticeLikeHeart {
|
|
SodaDialog(
|
|
title: I18n.Common.noticeTitle,
|
|
desc: I18n.LiveRoom.likeHeartNoticeDesc,
|
|
confirmButtonTitle: I18n.Common.confirm
|
|
) {
|
|
viewModel.isShowNoticeLikeHeart = false
|
|
}
|
|
}
|
|
|
|
if viewModel.isShowQuitPopup {
|
|
SodaDialog(
|
|
title: I18n.LiveRoom.quitTitle,
|
|
desc: I18n.LiveRoom.quitDesc,
|
|
confirmButtonTitle: I18n.Common.yes,
|
|
confirmButtonAction: {
|
|
viewModel.isShowQuitPopup = false
|
|
viewModel.quitRoom()
|
|
},
|
|
cancelButtonTitle: I18n.Common.no,
|
|
cancelButtonAction: {
|
|
viewModel.isShowQuitPopup = false
|
|
}
|
|
)
|
|
}
|
|
|
|
if viewModel.isShowLiveEndPopup {
|
|
SodaDialog(
|
|
title: I18n.LiveRoom.endTitle,
|
|
desc: I18n.LiveRoom.endDesc,
|
|
confirmButtonTitle: I18n.Common.yes,
|
|
confirmButtonAction: {
|
|
viewModel.isShowLiveEndPopup = false
|
|
viewModel.quitRoom()
|
|
},
|
|
cancelButtonTitle: I18n.Common.no,
|
|
cancelButtonAction: {
|
|
viewModel.isShowLiveEndPopup = false
|
|
}
|
|
)
|
|
}
|
|
|
|
if isShowChatDeleteDialog, let selectedChat = selectedChatForDelete {
|
|
SodaDialog(
|
|
title: I18n.LiveRoom.chatDeleteTitle,
|
|
desc: "\(selectedChat.nickname): \(selectedChat.chat)",
|
|
confirmButtonTitle: I18n.Common.delete,
|
|
confirmButtonAction: {
|
|
viewModel.deleteChat(selectedChat)
|
|
selectedChatForDelete = nil
|
|
isShowChatDeleteDialog = false
|
|
},
|
|
cancelButtonTitle: I18n.Common.cancel,
|
|
cancelButtonAction: {
|
|
selectedChatForDelete = nil
|
|
isShowChatDeleteDialog = 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)
|
|
.sodaToast(isPresented: $viewModel.isShowReportPopup, message: viewModel.reportMessage, autohideIn: 1.3)
|
|
}
|
|
|
|
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.gray22)
|
|
.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 isShowFollowNotifyDialog,
|
|
let liveRoomInfo = viewModel.liveRoomInfo,
|
|
liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) {
|
|
CreatorFollowNotifyDialog(
|
|
isShowing: $isShowFollowNotifyDialog,
|
|
onClickNotifyAll: {
|
|
guestFollowButtonTypeOverride = .following
|
|
viewModel.creatorFollow(follow: true, notify: true)
|
|
},
|
|
onClickNotifyNone: {
|
|
guestFollowButtonTypeOverride = .followingNoAlarm
|
|
viewModel.creatorFollow(follow: true, notify: false)
|
|
},
|
|
onClickUnFollow: {
|
|
guestFollowButtonTypeOverride = .follow
|
|
viewModel.creatorFollow(follow: false, notify: false)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
if viewModel.isShowRouletteSettings {
|
|
RouletteSettingsView(isShowing: $viewModel.isShowRouletteSettings, availableActive: true) { isActiveRoulette, message in
|
|
self.viewModel.setActiveRoulette(
|
|
isActiveRoulette: isActiveRoulette,
|
|
message: message
|
|
)
|
|
}
|
|
}
|
|
|
|
if !viewModel.roulettePreviewList.isEmpty && viewModel.isShowRoulettePreview {
|
|
RoulettePreviewDialog(
|
|
isShowing: $viewModel.isShowRoulettePreview,
|
|
title: nil,
|
|
onClickSpin: { viewModel.spinRoulette(rouletteId: $0) },
|
|
previewList: viewModel.roulettePreviewList
|
|
)
|
|
}
|
|
|
|
if viewModel.isShowRoulette {
|
|
RouletteViewDialog(isShowing: $viewModel.isShowRoulette, options: viewModel.rouletteItems, selectedOption: viewModel.rouletteSelectedItem) {
|
|
viewModel.sendRouletteDonation()
|
|
}
|
|
}
|
|
|
|
if viewModel.isLoading && viewModel.liveRoomInfo == nil {
|
|
LoadingView()
|
|
}
|
|
|
|
if viewModel.isV2VLoading {
|
|
LoadingView()
|
|
}
|
|
|
|
if isImageLoading {
|
|
LoadingView()
|
|
}
|
|
|
|
if isScreenCaptureProtected {
|
|
Color.black
|
|
.ignoresSafeArea()
|
|
.allowsHitTesting(true)
|
|
}
|
|
}
|
|
.overlay(alignment: .center) {
|
|
if !isScreenCaptureProtected {
|
|
ZStack {
|
|
// 로컬(롱프레스 중) 물 채우기 하트
|
|
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
|
|
.frame(width: 210, height: 210)
|
|
.allowsHitTesting(false)
|
|
.opacity(showWaterHeart ? 1 : 0)
|
|
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
|
|
|
|
// 원격 수신 시(또는 공통 트리거) 물 채우기 하트 - 0→1 스케일 업(1.0~1.5s 랜덤)
|
|
WaterHeartView(progress: viewModel.remoteWaterProgress,
|
|
show: viewModel.isShowRemoteBigHeart,
|
|
phase: viewModel.remoteWavePhase)
|
|
.frame(width: 210, height: 210)
|
|
.scaleEffect(viewModel.remoteHeartScale)
|
|
.allowsHitTesting(false)
|
|
// 롱프레스 로컬 연출 중에는 원격 하트를 숨겨 중복 방지
|
|
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
|
|
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
|
|
|
|
// 폭발 파편 (작은 하트, #ff959a)
|
|
ZStack {
|
|
ForEach(viewModel.bigHeartParticles) { p in
|
|
HeartShape()
|
|
.fill(Color(hex: "ff959a"))
|
|
.frame(width: p.size * p.scale, height: p.size * p.scale)
|
|
.rotationEffect(.degrees(p.rotation))
|
|
.offset(x: p.x, y: p.y)
|
|
.opacity(p.opacity)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
// drawingGroup은 레이어의 경계(Rect) 밖을 클립하므로, 전체 화면 크기로 확장해 클리핑 방지
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.drawingGroup(opaque: false, colorMode: .linear)
|
|
}
|
|
// 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동
|
|
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
|
|
}
|
|
}
|
|
.onReceive(heartWaveTimer) { _ in
|
|
guard isLongPressingHeart else { return }
|
|
let now = Date()
|
|
if longPressStartAt == nil { longPressStartAt = now }
|
|
let elapsed = now.timeIntervalSince(longPressStartAt!)
|
|
if elapsed >= 0.5 {
|
|
if !showWaterHeart {
|
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
|
|
showWaterHeart = true
|
|
}
|
|
}
|
|
let p = min(max((elapsed - 0.5) / 1.5, 0), 1)
|
|
waterProgress = p
|
|
// 파도 위상 진행
|
|
wavePhase += 0.25
|
|
} else {
|
|
showWaterHeart = false
|
|
waterProgress = 0
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
|
|
viewModel.quitRoom()
|
|
}
|
|
// 시스템 캡쳐 상태 변경(녹화 시작/종료 등)에 맞춰 보호 로직 갱신
|
|
.onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in
|
|
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
|
|
}
|
|
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
|
|
if isFrozen {
|
|
hideKeyboard()
|
|
}
|
|
}
|
|
.onChange(of: viewModel.role) { role in
|
|
guard isScreenCaptureProtected,
|
|
role == .SPEAKER,
|
|
!viewModel.isMute else {
|
|
return
|
|
}
|
|
|
|
// 캡쳐 중 리스너→스피커 전환 시 마이크가 즉시 켜지지 않도록 강제 음소거
|
|
viewModel.setMute(true)
|
|
shouldRestoreMicMuteAfterCapture = true
|
|
}
|
|
.ignoresSafeArea(.keyboard)
|
|
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
|
|
.sheet(
|
|
isPresented: $viewModel.isShowShareView,
|
|
onDismiss: { viewModel.shareMessage = "" },
|
|
content: {
|
|
ActivityViewController(activityItems: [viewModel.shareMessage])
|
|
}
|
|
)
|
|
.sheet(isPresented: $viewModel.isShowPhotoPicker) {
|
|
ImagePicker(
|
|
isShowing: $viewModel.isShowPhotoPicker,
|
|
selectedImage: $selectedPickedImage,
|
|
sourceType: .photoLibrary
|
|
)
|
|
}
|
|
.onChange(of: selectedPickedImage, perform: { newImage in
|
|
guard let newImage else {
|
|
return
|
|
}
|
|
|
|
isImageLoading = true
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let normalizedImage = newImage.normalizedForCrop()
|
|
DispatchQueue.main.async {
|
|
isImageLoading = false
|
|
selectedPickedImage = nil
|
|
cropSourceImage = normalizedImage
|
|
isShowImageCropper = true
|
|
}
|
|
}
|
|
})
|
|
.onDisappear {
|
|
isImageLoading = false
|
|
}
|
|
.sheet(isPresented: $isShowImageCropper, onDismiss: {
|
|
cropSourceImage = nil
|
|
}) {
|
|
if let cropSourceImage {
|
|
ImageCropEditorView(
|
|
image: cropSourceImage,
|
|
aspectPolicy: .free,
|
|
onCancel: {
|
|
isShowImageCropper = false
|
|
},
|
|
onComplete: { croppedImage in
|
|
viewModel.coverImage = croppedImage
|
|
isShowImageCropper = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.sheet(isPresented: $viewModel.isShowEditRoomInfoDialog) {
|
|
if let liveRoomInfo = viewModel.liveRoomInfo {
|
|
LiveRoomInfoEditDialog(
|
|
isShowing: $viewModel.isShowEditRoomInfoDialog,
|
|
isShowPhotoPicker: $viewModel.isShowPhotoPicker,
|
|
viewModel: viewModel,
|
|
isAdult: liveRoomInfo.isAdult,
|
|
isEntryMessageEnabled: viewModel.isEntryMessageEnabled,
|
|
isLoading: viewModel.isLoading,
|
|
currentTitle: liveRoomInfo.title,
|
|
currentNotice: liveRoomInfo.notice,
|
|
coverImageUrl: liveRoomInfo.coverImageUrl,
|
|
coverImage: viewModel.coverImage
|
|
) { newTitle, newNotice, isAdult, isEntryMessageEnabled in
|
|
self.viewModel.editLiveRoomInfo(
|
|
title: newTitle,
|
|
notice: newNotice,
|
|
isAdult: isAdult
|
|
)
|
|
self.viewModel.isEntryMessageEnabled = isEntryMessageEnabled
|
|
}
|
|
} else {
|
|
EmptyView()
|
|
.onAppear {
|
|
viewModel.isShowEditRoomInfoDialog = false
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $viewModel.isShowDonationRankingPopup) {
|
|
LiveRoomDonationRankingDialog(isShowing: $viewModel.isShowDonationRankingPopup)
|
|
}
|
|
.sheet(isPresented: $viewModel.isShowHeartRankingPopup) {
|
|
LiveRoomHeartRankingDialog(
|
|
isShowing: $viewModel.isShowHeartRankingPopup,
|
|
isShowPopup: $viewModel.isShowPopup,
|
|
errorMessage: viewModel.errorMessage,
|
|
isLoading: viewModel.isLoading,
|
|
heartStatus: viewModel.heartStatus
|
|
) {
|
|
viewModel.getHeartStatus()
|
|
}
|
|
}
|
|
.sheet(isPresented: $viewModel.isShowDonationMessagePopup) {
|
|
LiveRoomDonationMessageDialog(viewModel: viewModel, isShowing: $viewModel.isShowDonationMessagePopup)
|
|
}
|
|
.onChange(of: viewModel.liveRoomInfo?.isFollowing) { isFollowing in
|
|
if isFollowing == false {
|
|
guestFollowButtonTypeOverride = nil
|
|
}
|
|
}
|
|
.onChange(of: isShowChatDeleteDialog) { isShowing in
|
|
if isShowing {
|
|
hideKeyboard()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 <= 5 {
|
|
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 TextViewHeightKey: PreferenceKey {
|
|
static var defaultValue: CGFloat = .zero
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
value = nextValue()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 스크린샷/화면 녹화 노출 보호를 위해 SwiftUI 콘텐츠를 보안 컨테이너에 탑재
|
|
private struct ScreenCaptureSecureContainer<Content: View>: UIViewControllerRepresentable {
|
|
let content: Content
|
|
|
|
init(@ViewBuilder content: () -> Content) {
|
|
self.content = content()
|
|
}
|
|
|
|
func makeUIViewController(context: Context) -> ScreenCaptureSecureHostingController<Content> {
|
|
ScreenCaptureSecureHostingController(rootView: content)
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: ScreenCaptureSecureHostingController<Content>, context: Context) {
|
|
uiViewController.update(rootView: content)
|
|
}
|
|
}
|
|
|
|
// SwiftUI 뷰를 UIKit 보안 뷰 계층에 삽입하기 위한 호스팅 컨트롤러
|
|
private final class ScreenCaptureSecureHostingController<Content: View>: UIViewController {
|
|
private let secureContainerView = ScreenCaptureSecureView()
|
|
private let hostingController: UIHostingController<Content>
|
|
|
|
init(rootView: Content) {
|
|
hostingController = UIHostingController(rootView: rootView)
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func loadView() {
|
|
view = secureContainerView
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
hostingController.view.backgroundColor = .clear
|
|
addChild(hostingController)
|
|
secureContainerView.embed(contentView: hostingController.view)
|
|
hostingController.didMove(toParent: self)
|
|
}
|
|
|
|
func update(rootView: Content) {
|
|
hostingController.rootView = rootView
|
|
secureContainerView.embed(contentView: hostingController.view)
|
|
}
|
|
}
|
|
|
|
// isSecureTextEntry 기반 보안 렌더링 계층을 구성하는 컨테이너 뷰
|
|
private final class ScreenCaptureSecureView: UIView {
|
|
private let secureTextField = ScreenCaptureSecureTextField()
|
|
private weak var secureContentView: UIView?
|
|
private let failClosedOverlayView = UIView()
|
|
private var didLogFailClosedActivation = false
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
setup()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func setup() {
|
|
backgroundColor = .clear
|
|
|
|
// 보안 텍스트필드를 화면 전체에 배치해 하위 콘텐츠를 보안 계층으로 렌더링
|
|
secureTextField.translatesAutoresizingMaskIntoConstraints = false
|
|
secureTextField.isSecureTextEntry = true
|
|
secureTextField.backgroundColor = .clear
|
|
secureTextField.textColor = .clear
|
|
secureTextField.tintColor = .clear
|
|
|
|
addSubview(secureTextField)
|
|
NSLayoutConstraint.activate([
|
|
secureTextField.topAnchor.constraint(equalTo: topAnchor),
|
|
secureTextField.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
secureTextField.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
secureTextField.bottomAnchor.constraint(equalTo: bottomAnchor)
|
|
])
|
|
|
|
failClosedOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
|
failClosedOverlayView.backgroundColor = .black
|
|
failClosedOverlayView.isUserInteractionEnabled = true
|
|
failClosedOverlayView.isHidden = true
|
|
addSubview(failClosedOverlayView)
|
|
NSLayoutConstraint.activate([
|
|
failClosedOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
|
failClosedOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
failClosedOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
failClosedOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
|
])
|
|
|
|
// 텍스트필드 내부 보안 렌더링 뷰를 실제 콘텐츠 탑재 대상으로 사용
|
|
secureContentView = resolveSecureContentView()
|
|
updateFailClosedState(isActive: secureContentView == nil)
|
|
}
|
|
|
|
func embed(contentView: UIView) {
|
|
if secureContentView == nil {
|
|
secureContentView = resolveSecureContentView()
|
|
}
|
|
|
|
guard let secureContentView else {
|
|
contentView.removeFromSuperview()
|
|
updateFailClosedState(isActive: true)
|
|
return
|
|
}
|
|
|
|
updateFailClosedState(isActive: false)
|
|
|
|
guard contentView.superview !== secureContentView else {
|
|
return
|
|
}
|
|
|
|
// 기존 부모에서 분리 후 보안 콘텐츠 뷰에 다시 부착
|
|
contentView.removeFromSuperview()
|
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
secureContentView.addSubview(contentView)
|
|
NSLayoutConstraint.activate([
|
|
contentView.topAnchor.constraint(equalTo: secureContentView.topAnchor),
|
|
contentView.leadingAnchor.constraint(equalTo: secureContentView.leadingAnchor),
|
|
contentView.trailingAnchor.constraint(equalTo: secureContentView.trailingAnchor),
|
|
contentView.bottomAnchor.constraint(equalTo: secureContentView.bottomAnchor)
|
|
])
|
|
}
|
|
|
|
private func resolveSecureContentView() -> UIView? {
|
|
secureTextField.subviews.first {
|
|
let className = NSStringFromClass(type(of: $0))
|
|
return className.contains("CanvasView")
|
|
}
|
|
}
|
|
|
|
private func updateFailClosedState(isActive: Bool) {
|
|
failClosedOverlayView.isHidden = !isActive
|
|
|
|
guard isActive, !didLogFailClosedActivation else {
|
|
return
|
|
}
|
|
|
|
didLogFailClosedActivation = true
|
|
ERROR_LOG("[ScreenCaptureSecureView] secure canvas lookup failed. Activating fail-closed overlay.")
|
|
}
|
|
}
|
|
|
|
// 키보드 포커스를 차단해 보안 텍스트필드가 입력 UI로 동작하지 않도록 제한
|
|
private final class ScreenCaptureSecureTextField: UITextField {
|
|
override var canBecomeFirstResponder: Bool {
|
|
false
|
|
}
|
|
|
|
override func becomeFirstResponder() -> Bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
private extension LiveRoomViewV2 {
|
|
func applyScreenCaptureProtection(isCaptured: Bool) {
|
|
// 시스템 캡쳐 상태를 로컬 UI 상태와 동기화
|
|
isScreenCaptureProtected = isCaptured
|
|
|
|
if isCaptured {
|
|
// 원래 음소거가 아니었던 경우에만 강제 음소거 후 복원 플래그 저장
|
|
if !viewModel.isSpeakerMute {
|
|
viewModel.setSpeakerMute(true)
|
|
shouldRestoreSpeakerMuteAfterCapture = true
|
|
}
|
|
|
|
if !viewModel.isMute {
|
|
viewModel.setMute(true)
|
|
shouldRestoreMicMuteAfterCapture = true
|
|
}
|
|
return
|
|
}
|
|
|
|
releaseForcedCaptureMute()
|
|
}
|
|
|
|
func releaseForcedCaptureMute() {
|
|
// 캡쳐 보호로 인해 강제 변경한 상태만 선택적으로 원복
|
|
if shouldRestoreSpeakerMuteAfterCapture {
|
|
if viewModel.isSpeakerMute {
|
|
viewModel.setSpeakerMute(false)
|
|
}
|
|
shouldRestoreSpeakerMuteAfterCapture = false
|
|
}
|
|
|
|
if shouldRestoreMicMuteAfterCapture {
|
|
if viewModel.isMute {
|
|
viewModel.setMute(false)
|
|
}
|
|
shouldRestoreMicMuteAfterCapture = false
|
|
}
|
|
}
|
|
|
|
func guestFollowButtonType(liveRoomInfo: GetRoomInfoResponse) -> FollowButtonImageType {
|
|
if liveRoomInfo.isFollowing {
|
|
return guestFollowButtonTypeOverride ?? .following
|
|
}
|
|
|
|
return .follow
|
|
}
|
|
|
|
var isV2VCaptionVisible: Bool {
|
|
viewModel.isV2VCaptionOn &&
|
|
!viewModel.v2vCaptionText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
var v2vCaptionBottomInset: CGFloat {
|
|
isV2VCaptionVisible ? 120 : 70
|
|
}
|
|
}
|
|
|
|
struct LiveRoomViewV2_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
LiveRoomViewV2()
|
|
}
|
|
}
|