LNAudioCallPanel.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  1. //
  2. // LNAudioCallPanel.swift
  3. // Gami
  4. //
  5. // Created by OneeChan on 2026/2/1.
  6. //
  7. import Foundation
  8. import UIKit
  9. import SnapKit
  10. class LNAudioCallPanel: LNPopupView {
  11. private let background = UIImageView()
  12. private let avatar = UIImageView()
  13. private let nameLabel = UILabel()
  14. private let orderView = UIView()
  15. private let gameIc = UIImageView()
  16. private let orderStateLabel = UILabel()
  17. private let orderTimeLabel = UILabel()
  18. private let gameNameLabel = UILabel()
  19. private let gameCountLabel = UILabel()
  20. private let stateLabel = UILabel()
  21. private let onCallView = UIView()
  22. private let callOutView = UIView()
  23. private let callingView = UIView()
  24. private let durationLabel = UILabel()
  25. private let muteButton = UIButton()
  26. private let speakerButton = UIButton()
  27. private var timer: Timer?
  28. override init(frame: CGRect) {
  29. super.init(frame: frame)
  30. setupViews()
  31. LNEventDeliver.addObserver(self)
  32. }
  33. func toCallOut(uid: String) {
  34. callOutView.isHidden = false
  35. stateLabel.isHidden = false
  36. reloadUserInfo(uid: uid)
  37. }
  38. func onCallIn(uid: String) {
  39. onCallView.isHidden = false
  40. stateLabel.isHidden = false
  41. reloadUserInfo(uid: uid)
  42. }
  43. func resume() {
  44. guard let callInfo = LNIMManager.shared.curCallInfo else { return }
  45. if callInfo.beginTime > 0 {
  46. updateCallDuration()
  47. startTimer()
  48. callingView.isHidden = false
  49. stateLabel.isHidden = true
  50. getCurOrders(uid: callInfo.uid)
  51. } else if callInfo.isInCome {
  52. onCallView.isHidden = false
  53. stateLabel.isHidden = false
  54. } else {
  55. callOutView.isHidden = false
  56. stateLabel.isHidden = false
  57. }
  58. reloadUserInfo(uid: callInfo.uid)
  59. }
  60. required init?(coder: NSCoder) {
  61. fatalError("init(coder:) has not been implemented")
  62. }
  63. }
  64. extension LNAudioCallPanel {
  65. private func reloadUserInfo(uid: String) {
  66. LNProfileManager.shared.getUserProfileDetail(uid: uid) { [weak self] info in
  67. guard let self else { return }
  68. guard let info else { return }
  69. background.showAvatar(info.avatar)
  70. avatar.showAvatar(info.avatar)
  71. nameLabel.text = info.nickname
  72. }
  73. }
  74. private func getCurOrders(uid: String) {
  75. LNOrderManager.shared.getUnfinishedOrderWith(uid: uid, size: 1, next: nil) { [weak self] list, _ in
  76. guard let self else { return }
  77. guard let order = list?.first else { return }
  78. avatar.layer.cornerRadius = 60
  79. avatar.snp.updateConstraints { make in
  80. make.width.height.equalTo(120)
  81. }
  82. orderView.isHidden = false
  83. gameIc.sd_setImage(with: URL(string: order.categoryIcon))
  84. gameNameLabel.text = order.bizCategoryName
  85. orderTimeLabel.text = Double(order.createTime / 1_000).formattedFullDateWithTime()
  86. gameCountLabel.text = "x \(order.purchaseQty) \(order.unit)"
  87. orderStateLabel.text = order.statusUI.title
  88. }
  89. }
  90. private func updateCallDuration() {
  91. guard let callInfo = LNIMManager.shared.curCallInfo else { return }
  92. let duration = curTime - callInfo.beginTime
  93. durationLabel.text = duration.timeCountDisplay
  94. }
  95. private func startTimer() {
  96. stopTimer()
  97. let timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
  98. guard let self else { return }
  99. guard LNIMManager.shared.curCallInfo != nil else {
  100. stopTimer()
  101. return
  102. }
  103. updateCallDuration()
  104. }
  105. RunLoop.main.add(timer, forMode: .common)
  106. self.timer = timer
  107. }
  108. private func stopTimer() {
  109. timer?.invalidate()
  110. timer = nil
  111. }
  112. }
  113. extension LNAudioCallPanel: LNIMManagerNotify {
  114. func onVoiceCallBegin() {
  115. onCallView.isHidden = true
  116. callOutView.isHidden = true
  117. callingView.isHidden = false
  118. stateLabel.isHidden = true
  119. updateCallDuration()
  120. startTimer()
  121. if let callInfo = LNIMManager.shared.curCallInfo {
  122. getCurOrders(uid: callInfo.uid)
  123. }
  124. }
  125. func onVoiceCallEnd() {
  126. dismiss()
  127. stopTimer()
  128. }
  129. func onVoiceCallInfoChanged() {
  130. guard let callInfo = LNIMManager.shared.curCallInfo else { return }
  131. muteButton.setImage(callInfo.isMute ? .icCallMute : .icCallUnmute, for: .normal)
  132. let icon: UIImage = if callInfo.deviceType == .earpiece {
  133. .icCallSpeakerEarpiece
  134. } else {
  135. .icCallSpeakerPhone
  136. }
  137. speakerButton.setImage(icon, for: .normal)
  138. }
  139. }
  140. extension LNAudioCallPanel {
  141. private func setupViews() {
  142. containerHeight = .percent(1.0)
  143. let background = buildBackground()
  144. container.addSubview(background)
  145. background.snp.makeConstraints { make in
  146. make.edges.equalToSuperview()
  147. }
  148. let navBar = buildNavBar()
  149. container.addSubview(navBar)
  150. navBar.snp.makeConstraints { make in
  151. make.horizontalEdges.equalToSuperview()
  152. make.top.equalToSuperview()
  153. }
  154. let infoView = buildInfoView()
  155. container.addSubview(infoView)
  156. infoView.snp.makeConstraints { make in
  157. make.horizontalEdges.equalToSuperview().inset(16)
  158. make.top.greaterThanOrEqualTo(navBar.snp.bottom).offset(16)
  159. make.bottom.equalTo(container.snp.centerY).offset(-30).priority(.medium)
  160. }
  161. stateLabel.text = .init(key: "C00015")
  162. stateLabel.font = .body_xl
  163. stateLabel.textColor = .text_2
  164. container.addSubview(stateLabel)
  165. stateLabel.snp.makeConstraints { make in
  166. make.centerX.equalToSuperview()
  167. make.top.equalTo(infoView.snp.bottom)
  168. }
  169. let onCallView = buildOnCallView()
  170. container.addSubview(onCallView)
  171. onCallView.snp.makeConstraints { make in
  172. make.horizontalEdges.equalToSuperview().inset(48)
  173. make.bottom.equalToSuperview().offset(-100)
  174. }
  175. let callOutView = buildCallOutView()
  176. container.addSubview(callOutView)
  177. callOutView.snp.makeConstraints { make in
  178. make.horizontalEdges.equalToSuperview().inset(48)
  179. make.bottom.equalToSuperview().offset(-100)
  180. }
  181. let callingView = buildCallingView()
  182. container.addSubview(callingView)
  183. callingView.snp.makeConstraints { make in
  184. make.horizontalEdges.equalToSuperview().inset(48)
  185. make.bottom.equalToSuperview().offset(-100)
  186. }
  187. }
  188. private func buildBackground() -> UIView {
  189. background.backgroundColor = .lightGray
  190. background.contentMode = .scaleAspectFill
  191. let blurEffect = UIBlurEffect(style: .light)
  192. let blurView = UIVisualEffectView(effect: blurEffect)
  193. background.addSubview(blurView)
  194. blurView.snp.makeConstraints { make in
  195. make.edges.equalToSuperview()
  196. }
  197. // 可选:添加半透明遮罩,增强模糊层次感(毛玻璃常用搭配)
  198. let maskView = UIView(frame: blurView.bounds)
  199. maskView.backgroundColor = UIColor.black.withAlphaComponent(0.3) // 0.1~0.3为宜
  200. blurView.contentView.addSubview(maskView)
  201. maskView.snp.makeConstraints { make in
  202. make.edges.equalToSuperview()
  203. }
  204. return background
  205. }
  206. private func buildNavBar() -> UIView {
  207. let navBar = LNFakeNaviBar()
  208. let minButton = UIButton()
  209. minButton.setImage(.icCallMin, for: .normal)
  210. minButton.addAction(UIAction(handler: { [weak self] _ in
  211. guard let self else { return }
  212. dismiss()
  213. }), for: .touchUpInside)
  214. navBar.actionView.addSubview(minButton)
  215. minButton.snp.makeConstraints { make in
  216. make.leading.equalToSuperview().offset(16)
  217. make.centerY.equalToSuperview()
  218. make.width.height.equalTo(38)
  219. }
  220. return navBar
  221. }
  222. private func buildInfoView() -> UIView {
  223. let container = UIView()
  224. let stackView = UIStackView()
  225. stackView.axis = .vertical
  226. stackView.alignment = .center
  227. stackView.spacing = 4
  228. container.addSubview(stackView)
  229. stackView.snp.makeConstraints { make in
  230. make.edges.equalToSuperview()
  231. }
  232. avatar.layer.cornerRadius = 75
  233. avatar.clipsToBounds = true
  234. avatar.snp.makeConstraints { make in
  235. make.width.height.equalTo(150)
  236. }
  237. stackView.addArrangedSubview(avatar)
  238. nameLabel.font = .heading_h1
  239. nameLabel.textColor = .text_1
  240. stackView.addArrangedSubview(nameLabel)
  241. let orderView = buildOrderView()
  242. stackView.addArrangedSubview(orderView)
  243. orderView.snp.makeConstraints { make in
  244. make.width.equalToSuperview()
  245. }
  246. return container
  247. }
  248. private func buildOrderView() -> UIView {
  249. orderView.backgroundColor = .fill.withAlphaComponent(0.5)
  250. orderView.layer.cornerRadius = 12
  251. orderView.isHidden = true
  252. orderView.addSubview(gameIc)
  253. gameIc.snp.makeConstraints { make in
  254. make.top.equalToSuperview()
  255. make.leading.equalToSuperview().offset(10)
  256. make.width.height.equalTo(50)
  257. }
  258. let infoView = UIView()
  259. orderView.addSubview(infoView)
  260. infoView.snp.makeConstraints { make in
  261. make.centerY.equalTo(gameIc)
  262. make.leading.equalTo(gameIc.snp.trailing).offset(2)
  263. make.trailing.equalToSuperview().offset(-10)
  264. }
  265. orderStateLabel.font = .heading_h4
  266. orderStateLabel.textColor = .text_5
  267. infoView.addSubview(orderStateLabel)
  268. orderStateLabel.snp.makeConstraints { make in
  269. make.horizontalEdges.equalToSuperview()
  270. make.top.equalToSuperview()
  271. }
  272. orderTimeLabel.font = .body_xs
  273. orderTimeLabel.textColor = .text_4
  274. infoView.addSubview(orderTimeLabel)
  275. orderTimeLabel.snp.makeConstraints { make in
  276. make.horizontalEdges.equalToSuperview()
  277. make.bottom.equalToSuperview()
  278. make.top.equalTo(orderStateLabel.snp.bottom).offset(2)
  279. }
  280. let line = UIView()
  281. line.backgroundColor = .fill_4
  282. orderView.addSubview(line)
  283. line.snp.makeConstraints { make in
  284. make.horizontalEdges.equalToSuperview().inset(12)
  285. make.height.equalTo(0.5)
  286. make.bottom.equalTo(gameIc)
  287. }
  288. let gameInfo = UIView()
  289. orderView.addSubview(gameInfo)
  290. gameInfo.snp.makeConstraints { make in
  291. make.horizontalEdges.equalToSuperview()
  292. make.top.equalTo(gameIc.snp.bottom)
  293. make.bottom.equalToSuperview()
  294. make.height.equalTo(30)
  295. }
  296. gameNameLabel.font = .body_s
  297. gameNameLabel.textColor = .text_4
  298. gameInfo.addSubview(gameNameLabel)
  299. gameNameLabel.snp.makeConstraints { make in
  300. make.centerY.equalToSuperview()
  301. make.leading.equalToSuperview().offset(16)
  302. }
  303. gameCountLabel.font = .body_s
  304. gameCountLabel.textColor = .text_4
  305. gameCountLabel.setContentHuggingPriority(.required, for: .horizontal)
  306. gameCountLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  307. gameInfo.addSubview(gameCountLabel)
  308. gameCountLabel.snp.makeConstraints { make in
  309. make.centerY.equalToSuperview()
  310. make.trailing.equalToSuperview().offset(-16)
  311. make.leading.greaterThanOrEqualTo(gameNameLabel.snp.trailing).offset(16)
  312. }
  313. return orderView
  314. }
  315. private func buildOnCallView() -> UIView {
  316. onCallView.isHidden = true
  317. let stackView = UIStackView()
  318. stackView.distribution = .equalSpacing
  319. onCallView.addSubview(stackView)
  320. stackView.snp.makeConstraints { make in
  321. make.edges.equalToSuperview()
  322. }
  323. let rejectButton = UIButton()
  324. rejectButton.setImage(.icCallDecline, for: .normal)
  325. rejectButton.addAction(UIAction(handler: { _ in
  326. LNIMManager.shared.rejectVoiceCall()
  327. }), for: .touchUpInside)
  328. stackView.addArrangedSubview(rejectButton)
  329. let acceptButton = UIButton()
  330. acceptButton.setImage(.icCallAccept, for: .normal)
  331. acceptButton.addAction(UIAction(handler: { _ in
  332. LNIMManager.shared.acceptVoiceCall()
  333. }), for: .touchUpInside)
  334. stackView.addArrangedSubview(acceptButton)
  335. return onCallView
  336. }
  337. private func buildCallOutView() -> UIView {
  338. callOutView.isHidden = true
  339. let cancelButton = UIButton()
  340. cancelButton.setImage(.icCallDecline, for: .normal)
  341. cancelButton.addAction(UIAction(handler: { _ in
  342. LNIMManager.shared.hangupVoiceCall()
  343. }), for: .touchUpInside)
  344. callOutView.addSubview(cancelButton)
  345. cancelButton.snp.makeConstraints { make in
  346. make.verticalEdges.equalToSuperview()
  347. make.centerX.equalToSuperview()
  348. }
  349. return callOutView
  350. }
  351. private func buildCallingView() -> UIView {
  352. callingView.isHidden = true
  353. let stackView = UIStackView()
  354. stackView.alignment = .center
  355. stackView.distribution = .equalSpacing
  356. callingView.addSubview(stackView)
  357. stackView.snp.makeConstraints { make in
  358. make.horizontalEdges.equalToSuperview()
  359. make.bottom.equalToSuperview()
  360. }
  361. muteButton.setImage(.icCallUnmute, for: .normal)
  362. muteButton.addAction(UIAction(handler: { _ in
  363. LNIMManager.shared.switchVoiceCallMicrophone()
  364. }), for: .touchUpInside)
  365. stackView.addArrangedSubview(muteButton)
  366. let hangupButton = UIButton()
  367. hangupButton.setImage(.icCallDecline, for: .normal)
  368. hangupButton.addAction(UIAction(handler: { _ in
  369. LNIMManager.shared.hangupVoiceCall()
  370. }), for: .touchUpInside)
  371. stackView.addArrangedSubview(hangupButton)
  372. speakerButton.setImage(.icCallSpeakerEarpiece, for: .normal)
  373. speakerButton.addAction(UIAction(handler: { _ in
  374. // if DevicesUtil.isBluetoothHeadsetConnected {
  375. // let menu = LNVoiceCallSpeakerSelectPopoverMenu()
  376. // menu.pointAt(parentView: self, targetView: speakerButton) { type in
  377. // LNIMManager.shared.switchVoiceCallSpeakerType(type: type)
  378. // }
  379. // } else
  380. if let callInfo = LNIMManager.shared.curCallInfo {
  381. if callInfo.deviceType == .speakerphone {
  382. LNIMManager.shared.switchVoiceCallSpeakerType(type: .earpiece)
  383. } else {
  384. LNIMManager.shared.switchVoiceCallSpeakerType(type: .speakerphone)
  385. }
  386. }
  387. }), for: .touchUpInside)
  388. stackView.addArrangedSubview(speakerButton)
  389. durationLabel.font = .body_l
  390. durationLabel.textColor = .text_1
  391. callingView.addSubview(durationLabel)
  392. durationLabel.snp.makeConstraints { make in
  393. make.centerX.equalToSuperview()
  394. make.top.equalToSuperview()
  395. make.bottom.equalTo(stackView.snp.top).offset(-14)
  396. }
  397. return callingView
  398. }
  399. }
  400. private class LNVoiceCallSpeakerSelectPopoverMenu: UIView {
  401. private let holder = UIView()
  402. private var handler: ((LNIMVoiceCallSpeakerType) -> Void)?
  403. override init(frame: CGRect) {
  404. super.init(frame: frame)
  405. let touchView = UIView()
  406. touchView.onTap { [weak self] in
  407. guard let self else { return }
  408. dismiss()
  409. }
  410. addSubview(touchView)
  411. touchView.snp.makeConstraints { make in
  412. make.edges.equalToSuperview()
  413. }
  414. holder.backgroundColor = .fill.withAlphaComponent(0.8)
  415. holder.layer.cornerRadius = 10
  416. addSubview(holder)
  417. let stackView = UIStackView()
  418. stackView.axis = .vertical
  419. holder.addSubview(stackView)
  420. stackView.snp.makeConstraints { make in
  421. make.horizontalEdges.equalToSuperview().inset(4)
  422. make.top.equalToSuperview().offset(4)
  423. make.bottom.equalToSuperview().offset(-10)
  424. }
  425. let device = LNIMManager.shared.curCallInfo?.deviceType
  426. let bluetooth = buildMenuItem(ic: .icCallDeviceBluetooth, title: .init(key: "C00008"), isCheck: device == .bluetooth)
  427. bluetooth.onTap { [weak self] in
  428. guard let self else { return }
  429. dismiss()
  430. handler?(.bluetooth)
  431. }
  432. stackView.addArrangedSubview(bluetooth)
  433. let ear = buildMenuItem(ic: .icCallDeviceEar, title: .init(key: "C00006"), isCheck: device == .earpiece)
  434. ear.onTap { [weak self] in
  435. guard let self else { return }
  436. dismiss()
  437. handler?(.earpiece)
  438. }
  439. stackView.addArrangedSubview(ear)
  440. let speaker = buildMenuItem(ic: .icCallDeviceSpeaker, title: .init(key: "C00007"), isCheck: device == .speakerphone)
  441. speaker.onTap { [weak self] in
  442. guard let self else { return }
  443. dismiss()
  444. handler?(.speakerphone)
  445. }
  446. stackView.addArrangedSubview(speaker)
  447. let triangle = UIImageView()
  448. triangle.image = .icTriangleDown
  449. triangle.alpha = 0.8
  450. addSubview(triangle)
  451. triangle.snp.makeConstraints { make in
  452. make.centerX.equalTo(holder)
  453. make.top.equalTo(holder.snp.bottom)
  454. }
  455. }
  456. func pointAt(parentView: UIView, targetView: UIView, handler: @escaping (LNIMVoiceCallSpeakerType) -> Void) {
  457. parentView.addSubview(self)
  458. snp.makeConstraints { make in
  459. make.edges.equalToSuperview()
  460. }
  461. holder.snp.makeConstraints { make in
  462. make.centerX.equalTo(targetView)
  463. make.bottom.equalTo(targetView.snp.top).offset(-8)
  464. }
  465. self.handler = handler
  466. }
  467. func dismiss() {
  468. removeFromSuperview()
  469. }
  470. required init?(coder: NSCoder) {
  471. fatalError("init(coder:) has not been implemented")
  472. }
  473. private func buildMenuItem(ic: UIImage, title: String, isCheck: Bool) -> UIView {
  474. let container = UIView()
  475. container.snp.makeConstraints { make in
  476. make.height.equalTo(38)
  477. make.width.equalTo(124)
  478. }
  479. let icon = UIImageView(image: ic)
  480. container.addSubview(icon)
  481. icon.snp.makeConstraints { make in
  482. make.centerY.equalToSuperview()
  483. make.leading.equalToSuperview().offset(6)
  484. }
  485. let titleLabel = UILabel()
  486. titleLabel.font = .body_m
  487. titleLabel.textColor = isCheck ? .text_5 : .text_4
  488. titleLabel.text = title
  489. container.addSubview(titleLabel)
  490. titleLabel.snp.makeConstraints { make in
  491. make.centerY.equalToSuperview()
  492. make.leading.equalTo(icon.snp.trailing).offset(8)
  493. }
  494. if isCheck {
  495. let checkIc = UIImageView(image: .icCheckBlack)
  496. container.addSubview(checkIc)
  497. checkIc.snp.makeConstraints { make in
  498. make.centerY.equalToSuperview()
  499. make.trailing.equalToSuperview().offset(-6)
  500. make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(2)
  501. }
  502. }
  503. return container
  504. }
  505. }
  506. #if DEBUG
  507. import SwiftUI
  508. struct LNVoiceCallPanelPreview: UIViewRepresentable {
  509. func makeUIView(context: Context) -> some UIView {
  510. let container = UIView()
  511. container.backgroundColor = .lightGray
  512. let view = LNAudioCallPanel()
  513. view.popup()
  514. return container
  515. }
  516. func updateUIView(_ uiView: UIViewType, context: Context) { }
  517. }
  518. #Preview(body: {
  519. LNVoiceCallPanelPreview()
  520. })
  521. #endif