|
|
@@ -0,0 +1,451 @@
|
|
|
+//
|
|
|
+// LNVoiceCallPanel.swift
|
|
|
+// Gami
|
|
|
+//
|
|
|
+// Created by OneeChan on 2026/2/1.
|
|
|
+//
|
|
|
+
|
|
|
+import Foundation
|
|
|
+import UIKit
|
|
|
+import SnapKit
|
|
|
+
|
|
|
+
|
|
|
+class LNVoiceCallPanel: 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
|
|
|
+ reloadUserInfo(uid: uid)
|
|
|
+ }
|
|
|
+
|
|
|
+ func onCallIn(uid: String) {
|
|
|
+ onCallView.isHidden = false
|
|
|
+ reloadUserInfo(uid: uid)
|
|
|
+ }
|
|
|
+
|
|
|
+ func resume() {
|
|
|
+ guard let callInfo = LNIMManager.shared.curCallInfo else { return }
|
|
|
+ if callInfo.beginTime > 0 {
|
|
|
+ updateCallDuration()
|
|
|
+ callingView.isHidden = false
|
|
|
+ } else if callInfo.isInCome {
|
|
|
+ onCallView.isHidden = false
|
|
|
+ } else {
|
|
|
+ callOutView.isHidden = false
|
|
|
+ }
|
|
|
+
|
|
|
+ reloadUserInfo(uid: callInfo.uid)
|
|
|
+ }
|
|
|
+
|
|
|
+ required init?(coder: NSCoder) {
|
|
|
+ fatalError("init(coder:) has not been implemented")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+extension LNVoiceCallPanel {
|
|
|
+ private func reloadUserInfo(uid: String) {
|
|
|
+ LNProfileManager.shared.getUserProfile(uid: uid) { [weak self] info in
|
|
|
+ guard let self else { return }
|
|
|
+ guard let info else { return }
|
|
|
+ background.sd_setImage(with: URL(string: info.avatar))
|
|
|
+ avatar.sd_setImage(with: URL(string: info.avatar))
|
|
|
+ nameLabel.text = info.nickname
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 LNVoiceCallPanel: LNIMManagerNotify {
|
|
|
+ func onVoiceCallBegin() {
|
|
|
+ onCallView.isHidden = true
|
|
|
+ callOutView.isHidden = true
|
|
|
+ callingView.isHidden = false
|
|
|
+
|
|
|
+ updateCallDuration()
|
|
|
+ startTimer()
|
|
|
+ }
|
|
|
+
|
|
|
+ func onVoiceCallEnd() {
|
|
|
+ dismiss()
|
|
|
+ stopTimer()
|
|
|
+ }
|
|
|
+
|
|
|
+ func onVoiceCallInfoChanged() {
|
|
|
+ guard let callInfo = LNIMManager.shared.curCallInfo else { return }
|
|
|
+ muteButton.setImage(callInfo.isMute ? .icCallMute : .icCallUnmute, for: .normal)
|
|
|
+ speakerButton.setImage(callInfo.isSpeaker ? .icCallSpeakerPhone : .icCallSpeakerEarpiece, for: .normal)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+extension LNVoiceCallPanel {
|
|
|
+ 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.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()
|
|
|
+ let floatingView = LNVoiceCallFloatingView()
|
|
|
+ floatingView.show()
|
|
|
+ }), 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.equalToSuperview()
|
|
|
+ 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_2
|
|
|
+ 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
|
|
|
+ LNIMManager.shared.switchVoiceCallSpeakerType()
|
|
|
+ }), for: .touchUpInside)
|
|
|
+ stackView.addArrangedSubview(speakerButton)
|
|
|
+
|
|
|
+ durationLabel.font = .body_xl
|
|
|
+ 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
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+#if DEBUG
|
|
|
+
|
|
|
+import SwiftUI
|
|
|
+
|
|
|
+struct LNVoiceCallPanelPreview: UIViewRepresentable {
|
|
|
+ func makeUIView(context: Context) -> some UIView {
|
|
|
+ let container = UIView()
|
|
|
+ container.backgroundColor = .lightGray
|
|
|
+
|
|
|
+ let view = LNVoiceCallPanel()
|
|
|
+ view.popup()
|
|
|
+
|
|
|
+ return container
|
|
|
+ }
|
|
|
+
|
|
|
+ func updateUIView(_ uiView: UIViewType, context: Context) { }
|
|
|
+}
|
|
|
+
|
|
|
+#Preview(body: {
|
|
|
+ LNVoiceCallPanelPreview()
|
|
|
+})
|
|
|
+#endif
|
|
|
+
|