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

856 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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"
}
}
}