Visual_Novel_iOS/crush/Crush/Src/Modules/Chat/Phone/PhoneCallController.swift

856 lines
28 KiB
Swift
Raw Normal View History

2025-10-09 10:29:35 +00:00
//
// 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<AnyCancellable>()
var ringTone: SpeechModel?
// MARK: AI
/// AI1
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"
}
}
}