856 lines
28 KiB
Swift
856 lines
28 KiB
Swift
|
|
//
|
|||
|
|
// 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说话超时控制
|
|||
|
|
/// 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"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
}
|