// // PhoneCallController.swift // Crush // // Created by Leon on 2025/8/19. // import Lottie import UIKit import Combine import DateToolsSwift import SwiftDate enum PhoneCallUIState: Int { case onCallAIDefault = 0 case calling = 1 // 拨通了,表示STAR调用成功 case onCallAISaying = 2 case onCallAIListening = 3 // 即用户在说话 case onCallAIThinking = 4 var display:String{ switch self { case .onCallAIDefault: return "默认状态,未接通" case .calling: return "接通电话" case .onCallAISaying: return "AI Saying" case .onCallAIListening: return "AI Listening" case .onCallAIThinking: return "AI thinking" } } } class PhoneCallController: CLBaseViewController, PhoneCallViewModelDelegate { var bgIv: UIImageView! var effectView: UIVisualEffectView! // Case 1 var callingAvatar: CLImageView! // Case 2 var avatarsContainer: UIView! var avatarStackH: UIStackView! var avatar1: CLImageView! var avatar2: CLImageView! var hearIcon: UIImageView! var heartLottie: LottieAnimationView! var heartLevelLabel: UILabel! var nameLabel: UILabel! var heartLevelTag: RelationshipTag! /// 升降级的NoticeView var upDownNoticeView : SessionHeartLevelNoticeView! // Bottom var hangUpButton: EPIconDestructiveButton! var stateOfCallLabel: CLLabel! var interruptButton: StyleButton! var threeDotsAnimationView: LottieAnimationView! var wavingVoiceView : PhontVoiceWavingView! var wordsScrollContainer: LTScrollContainer! var spacerView: UIView! var wordsOtherSayLabel: LineSpaceLabel! lazy var testLabel = UILabel() lazy var testButton = UIButton() // MARK: Flag var callStartDate: Date = Date() var testStepIndex: Int = 0 // MARK: Data var aiId : Int? var viewModel = PhoneCallViewModel() @Published var viewState: PhoneCallUIState = .onCallAIDefault private var cancellables = Set() var ringTone: SpeechModel? // MARK: AI说话超时控制 /// AI说话超时时间(秒),默认1秒 var aiSpeakingTimeout: TimeInterval = 1.0 private var aiSpeakingTimer: Timer? // MARK: 用户说话超时控制 /// 用户停止说话后切换到思考状态的超时时间(秒),默认2秒 var userSpeakingTimeout: TimeInterval = 1.0 private var userSpeakingTimer: Timer? /// 标记用户是否刚刚说过话,只有用户说话后停止说话才会切换到thinking状态 private var userJustSpoke: Bool = false // MARK: - Control lazy var timestampThisTime = Date().timeStamp /// 回调电话状态,duration:(单位毫秒) var callEventAction:((_ type: CallType, _ duration: Int? ) -> Void)? override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. setupViews() setupDatas() setupEvents() playRingtone() #if DEBUG testUI() #endif } private func setupDatas() { guard let user = IMAIViewModel.shared.aiIMInfo else{ return } aiId = user.aiId viewModel.aiId = aiId viewModel.delegate = self refreshViews() // Default state viewState = .onCallAIDefault // #warning("test") DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {[weak self] in self?.startCall() } } private func refreshViews(){ guard let user = IMAIViewModel.shared.aiIMInfo else{ return } bgIv.loadImage(user.backgroundImg) // 心动等级 heartLevelLabel.text = "\(user.aiUserHeartbeatRelation?.heartbeatLevelNum ?? 1)" nameLabel.text = user.nickname callingAvatar.loadImage(user.headImg) avatar1.loadImage(user.headImg) avatar2.loadImage(UserCore.shared.user?.headImage) refreshOnlyRelationTag(user.aiUserHeartbeatRelation?.heartbeatVal) } private func refreshOnlyRelationTag(_ val : CGFloat?){ guard let user = IMAIViewModel.shared.aiIMInfo else{ return } heartLevelTag.bind(level: user.aiUserHeartbeatRelation?.heartbeatLevel, heartBeatVal: val, isShow: user.aiUserHeartbeatRelation?.isShow) } private func startCall(){ viewModel.loadRtc {[weak self] result in guard let `self` = self else { return } if result{ self.viewModel.opt(type: .START, timestamp: self.timestampThisTime) {[weak self] result in Hud.hideIndicator() if result{ SpeechManager.shared.stopPlayCurrent() self?.callStartDate = Date() self?.viewState = .calling }else{ self?.tapHangUp() } } }else{ Hud.hideIndicator() } } } private func testData(){ bgIv.image = UIImage(named: "egpic")?.cropImageTop(with: 1 / UIScreen.aspectRatio) heartLevelLabel.text = "1" nameLabel.text = "ASAF" stateOfCallLabel.text = "Click to interrupt" let url = UserCore.shared.user?.headImage avatar1.loadImage(url) avatar2.loadImage(url) let addition = "(Show surprise)" let words = "Hi, why did you remember to call me? It's not easy \(addition)" let aStr = words.withAttributes([.font(.t.tbl), .textColor(.text)]) let ranges = words.matchStrRange(addition) // words.range(of: addition) let att = [NSAttributedString.Key.font: UIFont.t.tbl, NSAttributedString.Key.foregroundColor: UIColor.c.ctsn, ] for range in ranges { aStr.addAttributes(att, range: range) } wordsOtherSayLabel.attributedText = aStr threeDotsAnimationView.play() } private func setupEvents() { navigationView.tapBackButtonAction = {[weak self] in self?.tapHangUp() } $viewState.sink {[weak self] state in #if DEBUG //Hud.hideToast() //Hud.toast(str: state.display) self?.testLabel.text = state.display #endif self?.refreshViewsState(state: state) }.store(in: &cancellables) IMAIViewModel.shared.$aiIMInfo.sink {[weak self] info in self?.refreshViews() }.store(in: &cancellables) IMAIViewModel.shared.$heartbeatVal.sink {[weak self] val in dlog("Only $heartbeatVal \(String(describing: val)) ") self?.refreshOnlyRelationTag(val) }.store(in: &cancellables) IMAIViewModel.shared.$heartbeatLevelUpString.sink {[weak self] string in guard let notice = string, notice.count > 0 else {return} self?.heartLottie.isHidden = false self?.heartLottie.play {[weak self] completed in self?.heartLottie.isHidden = true } self?.heartLevelTag.isHidden = true self?.upDownNoticeView.showUnlocked(string: notice) { self?.heartLevelTag.isHidden = false } }.store(in: &cancellables) IMAIViewModel.shared.$heartbeatLevelDownString.sink { [weak self] string in guard let notice = string, notice.count > 0 else {return} self?.heartLottie.isHidden = false self?.heartLottie.play {[weak self] completed in self?.heartLottie.isHidden = true } self?.heartLevelTag.isHidden = true self?.upDownNoticeView.showLose(string: notice) { self?.heartLevelTag.isHidden = false } }.store(in: &cancellables) viewModel.subTitleCallbck = {[weak self] text, userIdString in if let userId = Int(userIdString), userId == UserCore.shared.user?.userId{ // 本人在说话 self?.viewState = .onCallAIListening // 取消AI说话定时器 self?.clearAISpeakingTimer() // 重置用户说话定时器 self?.resetUserSpeakingTimer() // 标记用户刚刚说过话 self?.userJustSpoke = true }else{ self?.viewState = .onCallAISaying // 重置AI说话定时器 self?.resetAISpeakingTimer() // 取消用户说话定时器 self?.clearUserSpeakingTimer() self?.wordsOtherSayLabel.attributedText = self?.formatAttrubuteString(string: text) if let labelHeight = self?.wordsOtherSayLabel.size.height, let containerHeight = self?.wordsScrollContainer.size.height{ //dlog("label's height\(labelHeight), stack' s height\(containerHeight)") if labelHeight < containerHeight{ self?.spacerView.snp.updateConstraints { make in make.height.equalTo(containerHeight - labelHeight) } }else{ self?.spacerView.snp.updateConstraints { make in make.height.equalTo(0) } } } // 自动滚动到底部 self?.scrollToBottom() } } viewModel.leaveTheRoomForcedAction = {[weak self] in self?.close() } } func phoneCallLocalUserSaying(saying: Bool){ print("💬 syaing: \(saying)") if viewState == .onCallAIListening{ // #warning("to do, check状态对不对,待测试") wavingVoiceView.play() } } // MARK: - Action @objc private func tapHangUp() { // #warning("test") // viewModel.leaveRoom() // close(dismissFirst: true) // return // #warning("test") // //callEventAction?(.CALL_CANCEL, nil) // callEventAction?(.CALL_END, 6500) // close(dismissFirst: true) // return // 清理AI说话定时器 clearAISpeakingTimer() guard viewState.rawValue >= PhoneCallUIState.calling.rawValue else{ viewModel.leaveRoom() callEventAction?(.CALL_CANCEL, nil) close(dismissFirst: true) return } let now = Date() let miniseconds = now.timeStamp - self.callStartDate.timeStamp Hud.showIndicator() viewModel.opt(type: .STOP, timestamp: timestampThisTime, duration: miniseconds) {[weak self] result in Hud.hideIndicator() guard let self = self else{return} if result{ self.viewModel.leaveRoom() self.callEventAction?(.CALL_END, miniseconds) // 是否退出页面 self.close(dismissFirst: true) } } } @objc private func tapInterruptButton(){ Hud.showIndicator() viewModel.opt(type: .INTERRUPT, timestamp: timestampThisTime) { _ in Hud.hideIndicator() } } // MARK: - Functions private func playRingtone() { guard let path = Bundle.main.path(forResource: "call_ringtone", ofType: "mp3") else { return } let model = SpeechManager.shared.modelWithFilePath(path) ringTone = model model.stateChangedBlock = { [weak self] updatedModel in guard let self = self else { return } self.checkState(model: updatedModel) } SpeechManager.shared.startPlay(with: model) } private func checkState(model: SpeechModel) { guard model.loadState == .complete else { // SpeechManager.shared.stopPlay(with: model) // ❌ return } switch model.playState { case .default: if model.canAutoPlay { SpeechManager.shared.startPlay(with: model) } case .complete, .failed: SpeechManager.shared.stopPlay(with: model) case .playing: break } } // MARK: - Helper private func scrollToBottom() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } let bottomOffset = CGPoint(x: 0, y: max(0, self.wordsScrollContainer.scrollView.contentSize.height - self.wordsScrollContainer.scrollView.bounds.height)) self.wordsScrollContainer.scrollView.setContentOffset(bottomOffset, animated: true) } } /// 重置AI说话定时器 private func resetAISpeakingTimer() { // 取消之前的定时器 aiSpeakingTimer?.invalidate() // 创建新的定时器 aiSpeakingTimer = Timer.scheduledTimer(withTimeInterval: aiSpeakingTimeout, repeats: false) { [weak self] _ in // 超时后切换到AI不说话状态 self?.viewState = .onCallAIListening self?.wavingVoiceView.stop() } } /// 清理AI说话定时器 private func clearAISpeakingTimer() { aiSpeakingTimer?.invalidate() aiSpeakingTimer = nil } /// 重置用户说话定时器 private func resetUserSpeakingTimer() { // 取消之前的定时器 userSpeakingTimer?.invalidate() // 说明还在说话中... if wavingVoiceView.isAnimationPlaying == false{ wavingVoiceView.play() } // 创建新的定时器 userSpeakingTimer = Timer.scheduledTimer(withTimeInterval: userSpeakingTimeout, repeats: false) { [weak self] _ in // 只有用户刚刚说过话,停止说话才切换到思考状态 if self?.userJustSpoke == true { self?.viewState = .onCallAIThinking self?.userJustSpoke = false // 重置标志 } } } /// 清理用户说话定时器 private func clearUserSpeakingTimer() { userSpeakingTimer?.invalidate() userSpeakingTimer = nil } private func showThreeDotAnimation(_ show: Bool){ if show{ threeDotsAnimationView.isHidden = false threeDotsAnimationView.play() }else{ threeDotsAnimationView.isHidden = true threeDotsAnimationView.stop() } } private func showWaveWavingAnimation(_ show: Bool){ if show{ wavingVoiceView.isHidden = false wavingVoiceView.play() }else{ wavingVoiceView.isHidden = true wavingVoiceView.stop() } } private func refreshViewsState(state: PhoneCallUIState){ switch state { case .onCallAIDefault: showThreeDotAnimation(true) showWaveWavingAnimation(false) stateOfCallLabel.isHidden = false callingAvatar.isHidden = false avatarsContainer.isHidden = true interruptButton.isHidden = true threeDotsAnimationView.play() stateOfCallLabel.text = "Waiting to be connected"//"..." // 初始的加载过程中 case .calling: showThreeDotAnimation(true) showWaveWavingAnimation(false) stateOfCallLabel.isHidden = false callingAvatar.isHidden = true avatarsContainer.isHidden = false interruptButton.isHidden = true threeDotsAnimationView.play() stateOfCallLabel.text = " "//"Waiting to be connected" case .onCallAISaying: showThreeDotAnimation(false) showWaveWavingAnimation(false) stateOfCallLabel.isHidden = true callingAvatar.isHidden = true avatarsContainer.isHidden = false interruptButton.isHidden = false threeDotsAnimationView.stop() break case .onCallAIListening: showThreeDotAnimation(false) showWaveWavingAnimation(true) stateOfCallLabel.isHidden = false callingAvatar.isHidden = true avatarsContainer.isHidden = false interruptButton.isHidden = true stateOfCallLabel.text = "Listening..." break case .onCallAIThinking: showThreeDotAnimation(true) showWaveWavingAnimation(false) stateOfCallLabel.isHidden = false callingAvatar.isHidden = true avatarsContainer.isHidden = false interruptButton.isHidden = true stateOfCallLabel.text = "Thinking..." } } private func formatAttrubuteString(string: String) -> NSMutableAttributedString{ let content = string let aStr = content.withAttributes([.font(.t.tbm), .textColor(.text)]) let ranges = String.findBracketRanges(in: content) let att = [NSAttributedString.Key.font: UIFont.t.tbm, NSAttributedString.Key.foregroundColor: UIColor.c.ctsn, ] for range in ranges { aStr.addAttributes(att, range: range) } return aStr } // MARK: - UI private func setupViews() { navigationView.setupBackButtonCloseIcon() navigationView.bgView.alpha = 0 bgIv = { let v = UIImageView() v.contentMode = .scaleAspectFill view.addSubview(v) v.snp.makeConstraints { make in make.leading.top.trailing.equalToSuperview() make.bottom.equalToSuperview() } return v }() effectView = { let v = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) v.alpha = 0.9 view.addSubview(v) v.snp.makeConstraints { make in make.edges.equalToSuperview() } return v }() callingAvatar = { let v = CLImageView() v.cornerRadius = 64 v.backgroundColor = .c.csbn view.addSubview(v) v.snp.makeConstraints { make in make.size.equalTo(CGSize(width: 128, height: 128)) make.centerX.equalToSuperview() make.top.equalTo(navigationView.snp.bottom).offset(48) } return v }() avatarsContainer = { let v = UIView() view.addSubview(v) v.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.top.equalTo(navigationView.snp.bottom).offset(48) make.height.equalTo(128) } v.isHidden = true return v }() avatarStackH = { let v = UIStackView() v.spacing = 16 avatarsContainer.addSubview(v) v.snp.makeConstraints { make in make.center.equalToSuperview() } return v }() avatar1 = { let v = CLImageView() v.cornerRadius = 64 v.backgroundColor = .c.csbn avatarStackH.addArrangedSubview(v) v.snp.makeConstraints { make in make.size.equalTo(CGSize(width: 128, height: 128)) } return v }() avatar2 = { let v = CLImageView() v.cornerRadius = 64 v.backgroundColor = .c.csbn avatarStackH.addArrangedSubview(v) v.snp.makeConstraints { make in make.size.equalTo(CGSize(width: 128, height: 128)) } return v }() hearIcon = { let v = UIImageView() v.image = UIImage(named: "chat_level_heart") avatarsContainer.addSubview(v) v.snp.makeConstraints { make in make.size.equalTo(CGSize(width: 80, height: 80)) make.center.equalToSuperview() } return v }() heartLottie = { let animation = LottieAnimation.named("heartbeat_pink") let animationView = LottieAnimationView(animation: animation) animationView.contentMode = .scaleAspectFit animationView.loopMode = .playOnce animationView.backgroundBehavior = .pauseAndRestore animationView.backgroundColor = .clear animationView.size = CGSize(width: 44, height: 44) //avatarsContainer.addSubview(animationView) avatarsContainer.insertSubview(animationView, belowSubview: hearIcon) animationView.snp.makeConstraints { make in make.center.equalTo(hearIcon) make.size.equalTo(CGSize(width: 240, height: 210)) } animationView.isHidden = true return animationView }() heartLevelLabel = { let v = UILabel() v.textColor = .text v.font = .t.tnml avatarsContainer.addSubview(v) v.snp.makeConstraints { make in make.center.equalTo(hearIcon) } return v }() nameLabel = { let v = UILabel() v.font = .t.ttl v.textColor = .text v.textAlignment = .center view.addSubview(v) v.snp.makeConstraints { make in make.top.equalTo(avatarStackH.snp.bottom).offset(16) make.leading.equalToSuperview().offset(24) make.trailing.equalToSuperview().offset(-24) } return v }() heartLevelTag = { let v = RelationshipTag(size: .large) v.effectView.alpha = 0 view.addSubview(v) v.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(nameLabel.snp.bottom).offset(16) } return v }() upDownNoticeView = { let v = SessionHeartLevelNoticeView() view.addSubview(v) v.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(nameLabel.snp.bottom).offset(16) } return v }() setupBottomView() view.bringSubviewToFront(navigationView) } private func setupBottomView() { hangUpButton = { let v = EPIconDestructiveButton(radius: .round, iconSize: .large, iconCode: .iconCallHangup) v.addTarget(self, action: #selector(tapHangUp), for: .touchUpInside) view.addSubview(v) v.snp.makeConstraints { make in make.size.equalTo(CGSizeMake(48, 48)) make.centerX.equalToSuperview() make.bottom.equalToSuperview().offset(-24 - UIWindow.safeAreaBottom - 16) } return v }() stateOfCallLabel = { let v = CLLabel() v.font = .t.tlm v.textColor = .desc v.textAlignment = .center view.addSubview(v) v.snp.makeConstraints { make in make.leading.equalToSuperview().offset(24) make.trailing.equalToSuperview().offset(-24) make.bottom.equalTo(hangUpButton.snp.top).offset(-24) } return v }() threeDotsAnimationView = { let animation = LottieAnimation.named("three_dots_msg_loading") let animationView = LottieAnimationView(animation: animation) animationView.contentMode = .scaleAspectFit animationView.loopMode = .loop animationView.backgroundBehavior = .pauseAndRestore animationView.backgroundColor = .clear animationView.size = CGSize(width: 70, height: 40) view.addSubview(animationView) animationView.snp.makeConstraints { make in make.size.equalTo(CGSize(width: 70, height: 40)) make.centerX.equalToSuperview() //make.bottom.equalTo(stateOfCallLabel.snp.top).offset(-16) // -24 make.centerY.equalTo(stateOfCallLabel.snp.top).offset(-24) } return animationView }() wavingVoiceView = { let v = PhontVoiceWavingView() view.addSubview(v) v.snp.makeConstraints { make in make.size.equalTo(CGSize(width: 156, height: 24)) make.centerX.equalToSuperview() //make.bottom.equalTo(stateOfCallLabel.snp.top).offset(-24) make.centerY.equalTo(threeDotsAnimationView) } return v }() interruptButton = { let v = StyleButton(type: .custom) v.tertiary(size: .small) v.setTitle("Interrupt", for: .normal) v.addTarget(self, action: #selector(tapInterruptButton), for: .touchUpInside) view.addSubview(v) v.snp.makeConstraints { make in make.centerX.equalToSuperview() make.bottom.equalTo(hangUpButton.snp.top).offset(-24) } v.isHidden = true return v }() wordsScrollContainer = { let v = LTScrollContainer() view.addSubview(v) v.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.top.equalTo(heartLevelTag.snp.bottom).offset(16) //make.bottom.equalTo(stateOfCallLabel.snp.top).offset(-88) make.bottom.equalTo(hangUpButton.snp.top).offset(-108) } return v }() // 添加一个占位视图,让字幕始终显示在底部 let spacerView = UIView() wordsScrollContainer.stack.addArrangedSubview(spacerView) spacerView.snp.makeConstraints { make in //make.height.greaterThanOrEqualTo(0) make.height.equalTo(0) } self.spacerView = spacerView wordsOtherSayLabel = { let v = LineSpaceLabel() v.textColor = .white wordsScrollContainer.stack.addArrangedSubview(v) v.snp.makeConstraints { make in make.leading.equalToSuperview().offset(24) make.trailing.equalToSuperview().offset(-24) } return v }() // 设置 stack 的对齐方式,让内容靠底部显示 wordsScrollContainer.stack.alignment = .fill wordsScrollContainer.stack.distribution = .fill } deinit { clearAISpeakingTimer() clearUserSpeakingTimer() } // MARK: - Test private func testUI(){ view.addSubview(testLabel) testLabel.textColor = .white testLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(UIWindow.statusBarHeight + 8) make.centerX.equalToSuperview() } view.addSubview(testButton) testButton.setTitle("Switch view State", for: .normal) testButton.backgroundColor = .random testButton.snp.makeConstraints { make in make.top.equalTo(testLabel.snp.bottom).offset(12) make.centerX.equalToSuperview() } testButton.addTarget(self, action: #selector(testCall), for: .touchUpInside) let close = UIButton() view.addSubview(close) close.backgroundColor = .random close.setTitle("Close", for: .normal) close.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(testButton.snp.bottom).offset(8) } close.addTap {[weak self] btn in self?.close() } } @objc private func testCall(){ // self.testStepIndex += 1 // let nextState = PhoneCallUIState(rawValue: self.testStepIndex) ?? .onCallAIDefault // self.viewState = nextState viewState = .calling DispatchQueue.main.asyncAfter(deadline: .now() + 1) { IMAIViewModel.shared.heartbeatLevelUpString = "xxxxxxxxx" } } }