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.getUserProfile(uid: uid) { [weak self] info in
  67. guard let self else { return }
  68. guard let info else { return }
  69. background.sd_setImage(with: URL(string: info.avatar))
  70. avatar.sd_setImage(with: URL(string: 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.snp.updateConstraints { make in
  79. make.width.height.equalTo(120)
  80. }
  81. orderView.isHidden = false
  82. gameIc.sd_setImage(with: URL(string: order.categoryIcon))
  83. gameNameLabel.text = order.bizCategoryName
  84. orderTimeLabel.text = Double(order.createTime / 1_000).formattedFullDateWithTime()
  85. gameCountLabel.text = "x \(order.purchaseQty) \(order.unit)"
  86. orderStateLabel.text = order.statusUI.title
  87. }
  88. }
  89. private func updateCallDuration() {
  90. guard let callInfo = LNIMManager.shared.curCallInfo else { return }
  91. let duration = curTime - callInfo.beginTime
  92. durationLabel.text = duration.timeCountDisplay
  93. }
  94. private func startTimer() {
  95. stopTimer()
  96. let timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
  97. guard let self else { return }
  98. guard LNIMManager.shared.curCallInfo != nil else {
  99. stopTimer()
  100. return
  101. }
  102. updateCallDuration()
  103. }
  104. RunLoop.main.add(timer, forMode: .common)
  105. self.timer = timer
  106. }
  107. private func stopTimer() {
  108. timer?.invalidate()
  109. timer = nil
  110. }
  111. }
  112. extension LNAudioCallPanel: LNIMManagerNotify {
  113. func onVoiceCallBegin() {
  114. onCallView.isHidden = true
  115. callOutView.isHidden = true
  116. callingView.isHidden = false
  117. stateLabel.isHidden = true
  118. updateCallDuration()
  119. startTimer()
  120. if let callInfo = LNIMManager.shared.curCallInfo {
  121. getCurOrders(uid: callInfo.uid)
  122. }
  123. }
  124. func onVoiceCallEnd() {
  125. dismiss()
  126. stopTimer()
  127. }
  128. func onVoiceCallInfoChanged() {
  129. guard let callInfo = LNIMManager.shared.curCallInfo else { return }
  130. muteButton.setImage(callInfo.isMute ? .icCallMute : .icCallUnmute, for: .normal)
  131. let icon: UIImage = if callInfo.deviceType == .earpiece {
  132. .icCallSpeakerEarpiece
  133. } else {
  134. .icCallSpeakerPhone
  135. }
  136. speakerButton.setImage(icon, for: .normal)
  137. }
  138. }
  139. extension LNAudioCallPanel {
  140. private func setupViews() {
  141. containerHeight = .percent(1.0)
  142. let background = buildBackground()
  143. container.addSubview(background)
  144. background.snp.makeConstraints { make in
  145. make.edges.equalToSuperview()
  146. }
  147. let navBar = buildNavBar()
  148. container.addSubview(navBar)
  149. navBar.snp.makeConstraints { make in
  150. make.horizontalEdges.equalToSuperview()
  151. make.top.equalToSuperview()
  152. }
  153. let infoView = buildInfoView()
  154. container.addSubview(infoView)
  155. infoView.snp.makeConstraints { make in
  156. make.horizontalEdges.equalToSuperview().inset(16)
  157. make.top.greaterThanOrEqualTo(navBar.snp.bottom).offset(16)
  158. make.bottom.equalTo(container.snp.centerY).offset(-30).priority(.medium)
  159. }
  160. stateLabel.text = .init(key: "C00015")
  161. stateLabel.font = .body_xl
  162. stateLabel.textColor = .text_2
  163. container.addSubview(stateLabel)
  164. stateLabel.snp.makeConstraints { make in
  165. make.centerX.equalToSuperview()
  166. make.top.equalTo(infoView.snp.bottom)
  167. }
  168. let onCallView = buildOnCallView()
  169. container.addSubview(onCallView)
  170. onCallView.snp.makeConstraints { make in
  171. make.horizontalEdges.equalToSuperview().inset(48)
  172. make.bottom.equalToSuperview().offset(-100)
  173. }
  174. let callOutView = buildCallOutView()
  175. container.addSubview(callOutView)
  176. callOutView.snp.makeConstraints { make in
  177. make.horizontalEdges.equalToSuperview().inset(48)
  178. make.bottom.equalToSuperview().offset(-100)
  179. }
  180. let callingView = buildCallingView()
  181. container.addSubview(callingView)
  182. callingView.snp.makeConstraints { make in
  183. make.horizontalEdges.equalToSuperview().inset(48)
  184. make.bottom.equalToSuperview().offset(-100)
  185. }
  186. }
  187. private func buildBackground() -> UIView {
  188. background.backgroundColor = .lightGray
  189. background.contentMode = .scaleAspectFill
  190. let blurEffect = UIBlurEffect(style: .light)
  191. let blurView = UIVisualEffectView(effect: blurEffect)
  192. background.addSubview(blurView)
  193. blurView.snp.makeConstraints { make in
  194. make.edges.equalToSuperview()
  195. }
  196. // 可选:添加半透明遮罩,增强模糊层次感(毛玻璃常用搭配)
  197. let maskView = UIView(frame: blurView.bounds)
  198. maskView.backgroundColor = UIColor.black.withAlphaComponent(0.3) // 0.1~0.3为宜
  199. blurView.contentView.addSubview(maskView)
  200. maskView.snp.makeConstraints { make in
  201. make.edges.equalToSuperview()
  202. }
  203. return background
  204. }
  205. private func buildNavBar() -> UIView {
  206. let navBar = LNFakeNaviBar()
  207. let minButton = UIButton()
  208. minButton.setImage(.icCallMin, for: .normal)
  209. minButton.addAction(UIAction(handler: { [weak self] _ in
  210. guard let self else { return }
  211. dismiss()
  212. }), for: .touchUpInside)
  213. navBar.actionView.addSubview(minButton)
  214. minButton.snp.makeConstraints { make in
  215. make.leading.equalToSuperview().offset(16)
  216. make.centerY.equalToSuperview()
  217. make.width.height.equalTo(38)
  218. }
  219. return navBar
  220. }
  221. private func buildInfoView() -> UIView {
  222. let container = UIView()
  223. let stackView = UIStackView()
  224. stackView.axis = .vertical
  225. stackView.alignment = .center
  226. stackView.spacing = 4
  227. container.addSubview(stackView)
  228. stackView.snp.makeConstraints { make in
  229. make.edges.equalToSuperview()
  230. }
  231. avatar.layer.cornerRadius = 75
  232. avatar.clipsToBounds = true
  233. avatar.snp.makeConstraints { make in
  234. make.width.height.equalTo(150)
  235. }
  236. stackView.addArrangedSubview(avatar)
  237. nameLabel.font = .heading_h1
  238. nameLabel.textColor = .text_1
  239. stackView.addArrangedSubview(nameLabel)
  240. let orderView = buildOrderView()
  241. stackView.addArrangedSubview(orderView)
  242. orderView.snp.makeConstraints { make in
  243. make.width.equalToSuperview()
  244. }
  245. return container
  246. }
  247. private func buildOrderView() -> UIView {
  248. orderView.backgroundColor = .fill.withAlphaComponent(0.5)
  249. orderView.layer.cornerRadius = 12
  250. orderView.isHidden = true
  251. orderView.addSubview(gameIc)
  252. gameIc.snp.makeConstraints { make in
  253. make.top.equalToSuperview()
  254. make.leading.equalToSuperview().offset(10)
  255. make.width.height.equalTo(50)
  256. }
  257. let infoView = UIView()
  258. orderView.addSubview(infoView)
  259. infoView.snp.makeConstraints { make in
  260. make.centerY.equalTo(gameIc)
  261. make.leading.equalTo(gameIc.snp.trailing).offset(2)
  262. make.trailing.equalToSuperview().offset(-10)
  263. }
  264. orderStateLabel.font = .heading_h4
  265. orderStateLabel.textColor = .text_5
  266. infoView.addSubview(orderStateLabel)
  267. orderStateLabel.snp.makeConstraints { make in
  268. make.horizontalEdges.equalToSuperview()
  269. make.top.equalToSuperview()
  270. }
  271. orderTimeLabel.font = .body_xs
  272. orderTimeLabel.textColor = .text_4
  273. infoView.addSubview(orderTimeLabel)
  274. orderTimeLabel.snp.makeConstraints { make in
  275. make.horizontalEdges.equalToSuperview()
  276. make.bottom.equalToSuperview()
  277. make.top.equalTo(orderStateLabel.snp.bottom).offset(2)
  278. }
  279. let line = UIView()
  280. line.backgroundColor = .fill_4
  281. orderView.addSubview(line)
  282. line.snp.makeConstraints { make in
  283. make.horizontalEdges.equalToSuperview().inset(12)
  284. make.height.equalTo(0.5)
  285. make.bottom.equalTo(gameIc)
  286. }
  287. let gameInfo = UIView()
  288. orderView.addSubview(gameInfo)
  289. gameInfo.snp.makeConstraints { make in
  290. make.horizontalEdges.equalToSuperview()
  291. make.top.equalTo(gameIc.snp.bottom)
  292. make.bottom.equalToSuperview()
  293. make.height.equalTo(30)
  294. }
  295. gameNameLabel.font = .body_s
  296. gameNameLabel.textColor = .text_4
  297. gameInfo.addSubview(gameNameLabel)
  298. gameNameLabel.snp.makeConstraints { make in
  299. make.centerY.equalToSuperview()
  300. make.leading.equalToSuperview().offset(16)
  301. }
  302. gameCountLabel.font = .body_s
  303. gameCountLabel.textColor = .text_4
  304. gameCountLabel.setContentHuggingPriority(.required, for: .horizontal)
  305. gameCountLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  306. gameInfo.addSubview(gameCountLabel)
  307. gameCountLabel.snp.makeConstraints { make in
  308. make.centerY.equalToSuperview()
  309. make.trailing.equalToSuperview().offset(-16)
  310. make.leading.greaterThanOrEqualTo(gameNameLabel.snp.trailing).offset(16)
  311. }
  312. return orderView
  313. }
  314. private func buildOnCallView() -> UIView {
  315. onCallView.isHidden = true
  316. let stackView = UIStackView()
  317. stackView.distribution = .equalSpacing
  318. onCallView.addSubview(stackView)
  319. stackView.snp.makeConstraints { make in
  320. make.edges.equalToSuperview()
  321. }
  322. let rejectButton = UIButton()
  323. rejectButton.setImage(.icCallDecline, for: .normal)
  324. rejectButton.addAction(UIAction(handler: { _ in
  325. LNIMManager.shared.rejectVoiceCall()
  326. }), for: .touchUpInside)
  327. stackView.addArrangedSubview(rejectButton)
  328. let acceptButton = UIButton()
  329. acceptButton.setImage(.icCallAccept, for: .normal)
  330. acceptButton.addAction(UIAction(handler: { _ in
  331. LNIMManager.shared.acceptVoiceCall()
  332. }), for: .touchUpInside)
  333. stackView.addArrangedSubview(acceptButton)
  334. return onCallView
  335. }
  336. private func buildCallOutView() -> UIView {
  337. callOutView.isHidden = true
  338. let cancelButton = UIButton()
  339. cancelButton.setImage(.icCallDecline, for: .normal)
  340. cancelButton.addAction(UIAction(handler: { _ in
  341. LNIMManager.shared.hangupVoiceCall()
  342. }), for: .touchUpInside)
  343. callOutView.addSubview(cancelButton)
  344. cancelButton.snp.makeConstraints { make in
  345. make.verticalEdges.equalToSuperview()
  346. make.centerX.equalToSuperview()
  347. }
  348. return callOutView
  349. }
  350. private func buildCallingView() -> UIView {
  351. callingView.isHidden = true
  352. let stackView = UIStackView()
  353. stackView.alignment = .center
  354. stackView.distribution = .equalSpacing
  355. callingView.addSubview(stackView)
  356. stackView.snp.makeConstraints { make in
  357. make.horizontalEdges.equalToSuperview()
  358. make.bottom.equalToSuperview()
  359. }
  360. muteButton.setImage(.icCallUnmute, for: .normal)
  361. muteButton.addAction(UIAction(handler: { _ in
  362. LNIMManager.shared.switchVoiceCallMicrophone()
  363. }), for: .touchUpInside)
  364. stackView.addArrangedSubview(muteButton)
  365. let hangupButton = UIButton()
  366. hangupButton.setImage(.icCallDecline, for: .normal)
  367. hangupButton.addAction(UIAction(handler: { _ in
  368. LNIMManager.shared.hangupVoiceCall()
  369. }), for: .touchUpInside)
  370. stackView.addArrangedSubview(hangupButton)
  371. speakerButton.setImage(.icCallSpeakerEarpiece, for: .normal)
  372. speakerButton.addAction(UIAction(handler: { [weak self] _ in
  373. guard let self else { return }
  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