// // LNAudioCallPanel.swift // Gami // // Created by OneeChan on 2026/2/1. // import Foundation import UIKit import SnapKit class LNAudioCallPanel: LNPopupView { private let background = UIImageView() private let avatar = UIImageView() private let nameLabel = UILabel() private let orderView = UIView() private let gameIc = UIImageView() private let orderStateLabel = UILabel() private let orderTimeLabel = UILabel() private let gameNameLabel = UILabel() private let gameCountLabel = UILabel() private let stateLabel = UILabel() private let onCallView = UIView() private let callOutView = UIView() private let callingView = UIView() private let durationLabel = UILabel() private let muteButton = UIButton() private let speakerButton = UIButton() private var timer: Timer? override init(frame: CGRect) { super.init(frame: frame) setupViews() LNEventDeliver.addObserver(self) } func toCallOut(uid: String) { callOutView.isHidden = false stateLabel.isHidden = false reloadUserInfo(uid: uid) } func onCallIn(uid: String) { onCallView.isHidden = false stateLabel.isHidden = false reloadUserInfo(uid: uid) } func resume() { guard let callInfo = LNIMManager.shared.curCallInfo else { return } if callInfo.beginTime > 0 { updateCallDuration() startTimer() callingView.isHidden = false stateLabel.isHidden = true getCurOrders(uid: callInfo.uid) } else if callInfo.isInCome { onCallView.isHidden = false stateLabel.isHidden = false } else { callOutView.isHidden = false stateLabel.isHidden = false } reloadUserInfo(uid: callInfo.uid) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension LNAudioCallPanel { private func reloadUserInfo(uid: String) { LNProfileManager.shared.getUserProfileDetail(uid: uid) { [weak self] info in guard let self else { return } guard let info else { return } background.showAvatar(info.avatar) avatar.showAvatar(info.avatar) nameLabel.text = info.nickname } } private func getCurOrders(uid: String) { LNOrderManager.shared.getUnfinishedOrderWith(uid: uid, size: 1, next: nil) { [weak self] list, _ in guard let self else { return } guard let order = list?.first else { return } avatar.layer.cornerRadius = 60 avatar.snp.updateConstraints { make in make.width.height.equalTo(120) } orderView.isHidden = false gameIc.sd_setImage(with: URL(string: order.categoryIcon)) gameNameLabel.text = order.bizCategoryName orderTimeLabel.text = Double(order.createTime / 1_000).formattedFullDateWithTime() gameCountLabel.text = "x \(order.purchaseQty) \(order.unit)" orderStateLabel.text = order.statusUI.title } } private func updateCallDuration() { guard let callInfo = LNIMManager.shared.curCallInfo else { return } let duration = curTime - callInfo.beginTime durationLabel.text = duration.timeCountDisplay } private func startTimer() { stopTimer() let timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in guard let self else { return } guard LNIMManager.shared.curCallInfo != nil else { stopTimer() return } updateCallDuration() } RunLoop.main.add(timer, forMode: .common) self.timer = timer } private func stopTimer() { timer?.invalidate() timer = nil } } extension LNAudioCallPanel: LNIMManagerNotify { func onVoiceCallBegin() { onCallView.isHidden = true callOutView.isHidden = true callingView.isHidden = false stateLabel.isHidden = true updateCallDuration() startTimer() if let callInfo = LNIMManager.shared.curCallInfo { getCurOrders(uid: callInfo.uid) } } func onVoiceCallEnd() { dismiss() stopTimer() } func onVoiceCallInfoChanged() { guard let callInfo = LNIMManager.shared.curCallInfo else { return } muteButton.setImage(callInfo.isMute ? .icCallMute : .icCallUnmute, for: .normal) let icon: UIImage = if callInfo.deviceType == .earpiece { .icCallSpeakerEarpiece } else { .icCallSpeakerPhone } speakerButton.setImage(icon, for: .normal) } } extension LNAudioCallPanel { private func setupViews() { containerHeight = .percent(1.0) let background = buildBackground() container.addSubview(background) background.snp.makeConstraints { make in make.edges.equalToSuperview() } let navBar = buildNavBar() container.addSubview(navBar) navBar.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() } let infoView = buildInfoView() container.addSubview(infoView) infoView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(16) make.top.greaterThanOrEqualTo(navBar.snp.bottom).offset(16) make.bottom.equalTo(container.snp.centerY).offset(-30).priority(.medium) } stateLabel.text = .init(key: "C00015") stateLabel.font = .body_xl stateLabel.textColor = .text_2 container.addSubview(stateLabel) stateLabel.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(infoView.snp.bottom) } let onCallView = buildOnCallView() container.addSubview(onCallView) onCallView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(48) make.bottom.equalToSuperview().offset(-100) } let callOutView = buildCallOutView() container.addSubview(callOutView) callOutView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(48) make.bottom.equalToSuperview().offset(-100) } let callingView = buildCallingView() container.addSubview(callingView) callingView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(48) make.bottom.equalToSuperview().offset(-100) } } private func buildBackground() -> UIView { background.backgroundColor = .lightGray background.contentMode = .scaleAspectFill let blurEffect = UIBlurEffect(style: .light) let blurView = UIVisualEffectView(effect: blurEffect) background.addSubview(blurView) blurView.snp.makeConstraints { make in make.edges.equalToSuperview() } // 可选:添加半透明遮罩,增强模糊层次感(毛玻璃常用搭配) let maskView = UIView(frame: blurView.bounds) maskView.backgroundColor = UIColor.black.withAlphaComponent(0.3) // 0.1~0.3为宜 blurView.contentView.addSubview(maskView) maskView.snp.makeConstraints { make in make.edges.equalToSuperview() } return background } private func buildNavBar() -> UIView { let navBar = LNFakeNaviBar() let minButton = UIButton() minButton.setImage(.icCallMin, for: .normal) minButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } dismiss() }), for: .touchUpInside) navBar.actionView.addSubview(minButton) minButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() make.width.height.equalTo(38) } return navBar } private func buildInfoView() -> UIView { let container = UIView() let stackView = UIStackView() stackView.axis = .vertical stackView.alignment = .center stackView.spacing = 4 container.addSubview(stackView) stackView.snp.makeConstraints { make in make.edges.equalToSuperview() } avatar.layer.cornerRadius = 75 avatar.clipsToBounds = true avatar.snp.makeConstraints { make in make.width.height.equalTo(150) } stackView.addArrangedSubview(avatar) nameLabel.font = .heading_h1 nameLabel.textColor = .text_1 stackView.addArrangedSubview(nameLabel) let orderView = buildOrderView() stackView.addArrangedSubview(orderView) orderView.snp.makeConstraints { make in make.width.equalToSuperview() } return container } private func buildOrderView() -> UIView { orderView.backgroundColor = .fill.withAlphaComponent(0.5) orderView.layer.cornerRadius = 12 orderView.isHidden = true orderView.addSubview(gameIc) gameIc.snp.makeConstraints { make in make.top.equalToSuperview() make.leading.equalToSuperview().offset(10) make.width.height.equalTo(50) } let infoView = UIView() orderView.addSubview(infoView) infoView.snp.makeConstraints { make in make.centerY.equalTo(gameIc) make.leading.equalTo(gameIc.snp.trailing).offset(2) make.trailing.equalToSuperview().offset(-10) } orderStateLabel.font = .heading_h4 orderStateLabel.textColor = .text_5 infoView.addSubview(orderStateLabel) orderStateLabel.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() } orderTimeLabel.font = .body_xs orderTimeLabel.textColor = .text_4 infoView.addSubview(orderTimeLabel) orderTimeLabel.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() make.top.equalTo(orderStateLabel.snp.bottom).offset(2) } let line = UIView() line.backgroundColor = .fill_4 orderView.addSubview(line) line.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(12) make.height.equalTo(0.5) make.bottom.equalTo(gameIc) } let gameInfo = UIView() orderView.addSubview(gameInfo) gameInfo.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(gameIc.snp.bottom) make.bottom.equalToSuperview() make.height.equalTo(30) } gameNameLabel.font = .body_s gameNameLabel.textColor = .text_4 gameInfo.addSubview(gameNameLabel) gameNameLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(16) } gameCountLabel.font = .body_s gameCountLabel.textColor = .text_4 gameCountLabel.setContentHuggingPriority(.required, for: .horizontal) gameCountLabel.setContentCompressionResistancePriority(.required, for: .horizontal) gameInfo.addSubview(gameCountLabel) gameCountLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalToSuperview().offset(-16) make.leading.greaterThanOrEqualTo(gameNameLabel.snp.trailing).offset(16) } return orderView } private func buildOnCallView() -> UIView { onCallView.isHidden = true let stackView = UIStackView() stackView.distribution = .equalSpacing onCallView.addSubview(stackView) stackView.snp.makeConstraints { make in make.edges.equalToSuperview() } let rejectButton = UIButton() rejectButton.setImage(.icCallDecline, for: .normal) rejectButton.addAction(UIAction(handler: { _ in LNIMManager.shared.rejectVoiceCall() }), for: .touchUpInside) stackView.addArrangedSubview(rejectButton) let acceptButton = UIButton() acceptButton.setImage(.icCallAccept, for: .normal) acceptButton.addAction(UIAction(handler: { _ in LNIMManager.shared.acceptVoiceCall() }), for: .touchUpInside) stackView.addArrangedSubview(acceptButton) return onCallView } private func buildCallOutView() -> UIView { callOutView.isHidden = true let cancelButton = UIButton() cancelButton.setImage(.icCallDecline, for: .normal) cancelButton.addAction(UIAction(handler: { _ in LNIMManager.shared.hangupVoiceCall() }), for: .touchUpInside) callOutView.addSubview(cancelButton) cancelButton.snp.makeConstraints { make in make.verticalEdges.equalToSuperview() make.centerX.equalToSuperview() } return callOutView } private func buildCallingView() -> UIView { callingView.isHidden = true let stackView = UIStackView() stackView.alignment = .center stackView.distribution = .equalSpacing callingView.addSubview(stackView) stackView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() } muteButton.setImage(.icCallUnmute, for: .normal) muteButton.addAction(UIAction(handler: { _ in LNIMManager.shared.switchVoiceCallMicrophone() }), for: .touchUpInside) stackView.addArrangedSubview(muteButton) let hangupButton = UIButton() hangupButton.setImage(.icCallDecline, for: .normal) hangupButton.addAction(UIAction(handler: { _ in LNIMManager.shared.hangupVoiceCall() }), for: .touchUpInside) stackView.addArrangedSubview(hangupButton) speakerButton.setImage(.icCallSpeakerEarpiece, for: .normal) speakerButton.addAction(UIAction(handler: { _ in // if DevicesUtil.isBluetoothHeadsetConnected { // let menu = LNVoiceCallSpeakerSelectPopoverMenu() // menu.pointAt(parentView: self, targetView: speakerButton) { type in // LNIMManager.shared.switchVoiceCallSpeakerType(type: type) // } // } else if let callInfo = LNIMManager.shared.curCallInfo { if callInfo.deviceType == .speakerphone { LNIMManager.shared.switchVoiceCallSpeakerType(type: .earpiece) } else { LNIMManager.shared.switchVoiceCallSpeakerType(type: .speakerphone) } } }), for: .touchUpInside) stackView.addArrangedSubview(speakerButton) durationLabel.font = .body_l durationLabel.textColor = .text_1 callingView.addSubview(durationLabel) durationLabel.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalToSuperview() make.bottom.equalTo(stackView.snp.top).offset(-14) } return callingView } } private class LNVoiceCallSpeakerSelectPopoverMenu: UIView { private let holder = UIView() private var handler: ((LNIMVoiceCallSpeakerType) -> Void)? override init(frame: CGRect) { super.init(frame: frame) let touchView = UIView() touchView.onTap { [weak self] in guard let self else { return } dismiss() } addSubview(touchView) touchView.snp.makeConstraints { make in make.edges.equalToSuperview() } holder.backgroundColor = .fill.withAlphaComponent(0.8) holder.layer.cornerRadius = 10 addSubview(holder) let stackView = UIStackView() stackView.axis = .vertical holder.addSubview(stackView) stackView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(4) make.top.equalToSuperview().offset(4) make.bottom.equalToSuperview().offset(-10) } let device = LNIMManager.shared.curCallInfo?.deviceType let bluetooth = buildMenuItem(ic: .icCallDeviceBluetooth, title: .init(key: "C00008"), isCheck: device == .bluetooth) bluetooth.onTap { [weak self] in guard let self else { return } dismiss() handler?(.bluetooth) } stackView.addArrangedSubview(bluetooth) let ear = buildMenuItem(ic: .icCallDeviceEar, title: .init(key: "C00006"), isCheck: device == .earpiece) ear.onTap { [weak self] in guard let self else { return } dismiss() handler?(.earpiece) } stackView.addArrangedSubview(ear) let speaker = buildMenuItem(ic: .icCallDeviceSpeaker, title: .init(key: "C00007"), isCheck: device == .speakerphone) speaker.onTap { [weak self] in guard let self else { return } dismiss() handler?(.speakerphone) } stackView.addArrangedSubview(speaker) let triangle = UIImageView() triangle.image = .icTriangleDown triangle.alpha = 0.8 addSubview(triangle) triangle.snp.makeConstraints { make in make.centerX.equalTo(holder) make.top.equalTo(holder.snp.bottom) } } func pointAt(parentView: UIView, targetView: UIView, handler: @escaping (LNIMVoiceCallSpeakerType) -> Void) { parentView.addSubview(self) snp.makeConstraints { make in make.edges.equalToSuperview() } holder.snp.makeConstraints { make in make.centerX.equalTo(targetView) make.bottom.equalTo(targetView.snp.top).offset(-8) } self.handler = handler } func dismiss() { removeFromSuperview() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func buildMenuItem(ic: UIImage, title: String, isCheck: Bool) -> UIView { let container = UIView() container.snp.makeConstraints { make in make.height.equalTo(38) make.width.equalTo(124) } let icon = UIImageView(image: ic) container.addSubview(icon) icon.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(6) } let titleLabel = UILabel() titleLabel.font = .body_m titleLabel.textColor = isCheck ? .text_5 : .text_4 titleLabel.text = title container.addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalTo(icon.snp.trailing).offset(8) } if isCheck { let checkIc = UIImageView(image: .icCheckBlack) container.addSubview(checkIc) checkIc.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalToSuperview().offset(-6) make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(2) } } return container } } #if DEBUG import SwiftUI struct LNVoiceCallPanelPreview: UIViewRepresentable { func makeUIView(context: Context) -> some UIView { let container = UIView() container.backgroundColor = .lightGray let view = LNAudioCallPanel() view.popup() return container } func updateUIView(_ uiView: UIViewType, context: Context) { } } #Preview(body: { LNVoiceCallPanelPreview() }) #endif