Ver Fonte

feat: 增加 1v1 通话功能

陈文艺 há 1 mês atrás
pai
commit
ce77cbe743
51 ficheiros alterados com 1392 adições e 95 exclusões
  1. 28 18
      Lanu.xcodeproj/project.pbxproj
  2. 3 3
      Lanu.xcworkspace/xcshareddata/swiftpm/Package.resolved
  3. 6 0
      Lanu/Assets.xcassets/IM/Call/Contents.json
  4. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_call_accept.imageset/Contents.json
  5. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_accept.imageset/ic_call_accept@2x.png
  6. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_accept.imageset/ic_call_accept@3x.png
  7. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_call_decline.imageset/Contents.json
  8. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_decline.imageset/ic_call_decline@2x.png
  9. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_decline.imageset/ic_call_decline@3x.png
  10. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_call_min.imageset/Contents.json
  11. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_min.imageset/ic_call_min@2x.png
  12. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_min.imageset/ic_call_min@3x.png
  13. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_call_mute.imageset/Contents.json
  14. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_mute.imageset/ic_call_mute@2x.png
  15. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_mute.imageset/ic_call_mute@3x.png
  16. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_call_speaker_earpiece.imageset/Contents.json
  17. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_speaker_earpiece.imageset/ic_call_speaker_earpiece@2x.png
  18. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_speaker_earpiece.imageset/ic_call_speaker_earpiece@3x.png
  19. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_call_speaker_phone.imageset/Contents.json
  20. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_speaker_phone.imageset/ic_call_speaker_phone@2x.png
  21. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_speaker_phone.imageset/ic_call_speaker_phone@3x.png
  22. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_call_unmute.imageset/Contents.json
  23. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_unmute.imageset/ic_call_unmute@2x.png
  24. BIN
      Lanu/Assets.xcassets/IM/Call/ic_call_unmute.imageset/ic_call_unmute@3x.png
  25. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_calling.imageset/Contents.json
  26. BIN
      Lanu/Assets.xcassets/IM/Call/ic_calling.imageset/ic_calling@2x.png
  27. BIN
      Lanu/Assets.xcassets/IM/Call/ic_calling.imageset/ic_calling@3x.png
  28. 22 0
      Lanu/Assets.xcassets/IM/Call/ic_im_chat_call_over.imageset/Contents.json
  29. BIN
      Lanu/Assets.xcassets/IM/Call/ic_im_chat_call_over.imageset/ic_im_chat_call_over@2x.png
  30. BIN
      Lanu/Assets.xcassets/IM/Call/ic_im_chat_call_over.imageset/ic_im_chat_call_over@3x.png
  31. 4 0
      Lanu/Common/Extension/Double+Extension.swift
  32. 19 0
      Lanu/Common/Extension/Int+Extension.swift
  33. 3 0
      Lanu/Common/Views/LNPopupView.swift
  34. 115 0
      Lanu/Localizable.xcstrings
  35. 156 7
      Lanu/Manager/IM/LNIMManager.swift
  36. 62 9
      Lanu/Manager/IM/LNIMMessageData.swift
  37. 2 2
      Lanu/Manager/IM/TUIUtils/V2TIMConversation+Extension.swift
  38. 1 1
      Lanu/Manager/Network/LNHttpManager.swift
  39. 2 2
      Lanu/Views/Game/Join/Input/Example/LNJoinUsPhotoExamplePanel.swift
  40. 1 2
      Lanu/Views/Game/Skill/Edit/LNSkillFieldVoiceEditView.swift
  41. 64 0
      Lanu/Views/IM/Chat/Cells/LNIMChatCallMessageCell.swift
  42. 2 2
      Lanu/Views/IM/Chat/GameMate/LNIMChatGameMateOrderView.swift
  43. 20 16
      Lanu/Views/IM/Chat/LNIMChatViewController.swift
  44. 59 22
      Lanu/Views/IM/Chat/ViewModel/LNIMChatViewModel.swift
  45. 167 0
      Lanu/Views/IM/Chat/VoiceCall/LNVoiceCallFloatingView.swift
  46. 451 0
      Lanu/Views/IM/Chat/VoiceCall/LNVoiceCallPanel.swift
  47. 2 2
      Lanu/Views/IM/Notify/LNIMOfficialMessageViewController.swift
  48. 1 1
      Lanu/Views/Order/Detail/LNOrderDetailViewController.swift
  49. 4 7
      Lanu/Views/Profile/Edit/LNEditVoicePanel.swift
  50. 3 0
      Podfile
  51. 19 1
      Podfile.lock

+ 28 - 18
Lanu.xcodeproj/project.pbxproj

@@ -9,13 +9,13 @@
 /* Begin PBXBuildFile section */
 		314B1B286681A79A6D153299 /* Pods_Gami.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C4E0C09312B6CA8A283AD62F /* Pods_Gami.framework */; };
 		FB696C172EC96C0F00FAD639 /* MJRefresh in Frameworks */ = {isa = PBXBuildFile; productRef = FB696C162EC96C0F00FAD639 /* MJRefresh */; };
+		FB8316682F334C2C000396D5 /* AutoCodable in Frameworks */ = {isa = PBXBuildFile; productRef = FB8316672F334C2C000396D5 /* AutoCodable */; };
 		FB9CD1192EC1EEA10033B14B /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = FB9CD1182EC1EEA10033B14B /* FirebaseCore */; };
 		FB9CD11B2EC1EEA10033B14B /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB9CD11A2EC1EEA10033B14B /* FirebaseCrashlytics */; };
 		FB9CD11E2EC1EEF30033B14B /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = FB9CD11D2EC1EEF30033B14B /* GoogleSignIn */; };
 		FB9EAE7B2F011ACD00E77B7C /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB9EAE7A2F011ACD00E77B7C /* StoreKit.framework */; };
 		FB9FCD262EF25D6B00DDAAC9 /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = FB9FCD252EF25D6B00DDAAC9 /* SDWebImage */; };
 		FBA06B692F18F7D300DDD745 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = FBA06B682F18F7D300DDD745 /* SnapKit */; };
-		FBECA9C42EC1C5250013A5E6 /* AutoCodable in Frameworks */ = {isa = PBXBuildFile; productRef = FBECA9C32EC1C5250013A5E6 /* AutoCodable */; };
 		FBECA9CA2EC1C8240013A5E6 /* CocoaLumberjackSwift in Frameworks */ = {isa = PBXBuildFile; productRef = FBECA9C92EC1C8240013A5E6 /* CocoaLumberjackSwift */; };
 		FBECAA1D2EC1C8860013A5E6 /* CocoaLumberjackSwiftLogBackend in Frameworks */ = {isa = PBXBuildFile; productRef = FBECAA1C2EC1C8860013A5E6 /* CocoaLumberjackSwiftLogBackend */; };
 /* End PBXBuildFile section */
@@ -47,6 +47,7 @@
 				"Common/Extension/Date+Extension.swift",
 				"Common/Extension/DispatchQueue+Extension.swift",
 				"Common/Extension/Double+Extension.swift",
+				"Common/Extension/Int+Extension.swift",
 				"Common/Extension/NSObject+Extension.swift",
 				"Common/Extension/String+Extension.swift",
 				"Common/Extension/TimeInterval+Extension.swift",
@@ -219,6 +220,7 @@
 				Views/Home/LNHomeTopTabView.swift,
 				Views/Home/LNHomeViewController.swift,
 				Views/IM/Chat/Cells/LNIMChatBaseMessageCell.swift,
+				Views/IM/Chat/Cells/LNIMChatCallMessageCell.swift,
 				Views/IM/Chat/Cells/LNIMChatImageMessageCell.swift,
 				Views/IM/Chat/Cells/LNIMChatOrderMessageCell.swift,
 				Views/IM/Chat/Cells/LNIMChatSystemMessageCell.swift,
@@ -238,6 +240,8 @@
 				Views/IM/Chat/LNIMChatViewController.swift,
 				Views/IM/Chat/UserMenu/LNIMChatUserMenuView.swift,
 				Views/IM/Chat/ViewModel/LNIMChatViewModel.swift,
+				Views/IM/Chat/VoiceCall/LNVoiceCallFloatingView.swift,
+				Views/IM/Chat/VoiceCall/LNVoiceCallPanel.swift,
 				Views/IM/ConversationList/LNIMConversationCell.swift,
 				Views/IM/ConversationList/LNIMConversationListController.swift,
 				Views/IM/ConversationList/LNIMNotificationPermissionView.swift,
@@ -341,8 +345,6 @@
 		};
 		FBB67E232EC48B440070E686 /* ThirdParty */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
-			exceptions = (
-			);
 			path = ThirdParty;
 			sourceTree = "<group>";
 		};
@@ -358,8 +360,8 @@
 				FB9CD1192EC1EEA10033B14B /* FirebaseCore in Frameworks */,
 				FB9FCD262EF25D6B00DDAAC9 /* SDWebImage in Frameworks */,
 				FB696C172EC96C0F00FAD639 /* MJRefresh in Frameworks */,
+				FB8316682F334C2C000396D5 /* AutoCodable in Frameworks */,
 				FB9CD11B2EC1EEA10033B14B /* FirebaseCrashlytics in Frameworks */,
-				FBECA9C42EC1C5250013A5E6 /* AutoCodable in Frameworks */,
 				FB9CD11E2EC1EEF30033B14B /* GoogleSignIn in Frameworks */,
 				FB9EAE7B2F011ACD00E77B7C /* StoreKit.framework in Frameworks */,
 				314B1B286681A79A6D153299 /* Pods_Gami.framework in Frameworks */,
@@ -460,13 +462,13 @@
 			mainGroup = FBFE13B72EBC39B000DCE6E9;
 			minimizedProjectReferenceProxies = 1;
 			packageReferences = (
-				FBECA9C22EC1C5250013A5E6 /* XCRemoteSwiftPackageReference "AutoCodable" */,
 				FBECAA192EC1C8860013A5E6 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */,
 				FB9CD1172EC1EEA10033B14B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
 				FB9CD11C2EC1EEF30033B14B /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */,
 				FB696C152EC96C0F00FAD639 /* XCRemoteSwiftPackageReference "MJRefresh" */,
 				FB9FCD242EF25D6B00DDAAC9 /* XCRemoteSwiftPackageReference "SDWebImage" */,
 				FBA06B672F18F7D300DDD745 /* XCRemoteSwiftPackageReference "SnapKit" */,
+				FB8316662F334C2C000396D5 /* XCRemoteSwiftPackageReference "AutoCodable" */,
 			);
 			preferredProjectObjectVersion = 77;
 			productRefGroup = FBFE13C12EBC39B000DCE6E9 /* Products */;
@@ -497,10 +499,14 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources-${CONFIGURATION}-input-files.xcfilelist",
 			);
+			inputPaths = (
+			);
 			name = "[CP] Copy Pods Resources";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources-${CONFIGURATION}-output-files.xcfilelist",
 			);
+			outputPaths = (
+			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources.sh\"\n";
@@ -536,10 +542,14 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 			);
+			inputPaths = (
+			);
 			name = "[CP] Embed Pods Frameworks";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks-${CONFIGURATION}-output-files.xcfilelist",
 			);
+			outputPaths = (
+			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks.sh\"\n";
@@ -816,6 +826,14 @@
 				minimumVersion = 3.7.9;
 			};
 		};
+		FB8316662F334C2C000396D5 /* XCRemoteSwiftPackageReference "AutoCodable" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "http://8.134.139.102:10880/chenwenyi/AutoCodable.git";
+			requirement = {
+				kind = upToNextMajorVersion;
+				minimumVersion = 1.0.1;
+			};
+		};
 		FB9CD1172EC1EEA10033B14B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
 			isa = XCRemoteSwiftPackageReference;
 			repositoryURL = "http://8.134.139.102:10880/chenwenyi/firebase-ios-sdk.git";
@@ -848,14 +866,6 @@
 				minimumVersion = 5.7.2;
 			};
 		};
-		FBECA9C22EC1C5250013A5E6 /* XCRemoteSwiftPackageReference "AutoCodable" */ = {
-			isa = XCRemoteSwiftPackageReference;
-			repositoryURL = "http://8.134.139.102:10880/chenwenyi/AutoCodable.git";
-			requirement = {
-				kind = upToNextMajorVersion;
-				minimumVersion = 1.0.0;
-			};
-		};
 		FBECA9C82EC1C8240013A5E6 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = {
 			isa = XCRemoteSwiftPackageReference;
 			repositoryURL = "http://8.134.139.102:10880/chenwenyi/CocoaLumberjack.git";
@@ -880,6 +890,11 @@
 			package = FB696C152EC96C0F00FAD639 /* XCRemoteSwiftPackageReference "MJRefresh" */;
 			productName = MJRefresh;
 		};
+		FB8316672F334C2C000396D5 /* AutoCodable */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = FB8316662F334C2C000396D5 /* XCRemoteSwiftPackageReference "AutoCodable" */;
+			productName = AutoCodable;
+		};
 		FB9CD1182EC1EEA10033B14B /* FirebaseCore */ = {
 			isa = XCSwiftPackageProductDependency;
 			package = FB9CD1172EC1EEA10033B14B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
@@ -905,11 +920,6 @@
 			package = FBA06B672F18F7D300DDD745 /* XCRemoteSwiftPackageReference "SnapKit" */;
 			productName = SnapKit;
 		};
-		FBECA9C32EC1C5250013A5E6 /* AutoCodable */ = {
-			isa = XCSwiftPackageProductDependency;
-			package = FBECA9C22EC1C5250013A5E6 /* XCRemoteSwiftPackageReference "AutoCodable" */;
-			productName = AutoCodable;
-		};
 		FBECA9C92EC1C8240013A5E6 /* CocoaLumberjackSwift */ = {
 			isa = XCSwiftPackageProductDependency;
 			package = FBECA9C82EC1C8240013A5E6 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */;

+ 3 - 3
Lanu.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "b2c25fbaf8ba5f006703fea95f1b23e611935d07e0928897c4763edfcf096f44",
+  "originHash" : "bf0254c02f28b6a521e188811c39548ebf8a2568c812a254677f42a61aec88bb",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",
@@ -33,8 +33,8 @@
       "kind" : "remoteSourceControl",
       "location" : "http://8.134.139.102:10880/chenwenyi/AutoCodable.git",
       "state" : {
-        "revision" : "12c4528b69ecf970c33d4b9bfd20e5ace7f4280f",
-        "version" : "1.0.0"
+        "revision" : "730ad21275813109ae1e4329a1d7b8d3326b4f26",
+        "version" : "1.0.1"
       }
     },
     {

+ 6 - 0
Lanu/Assets.xcassets/IM/Call/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_call_accept.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_call_accept@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_call_accept@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_call_accept.imageset/ic_call_accept@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_call_accept.imageset/ic_call_accept@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_call_decline.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_call_decline@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_call_decline@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_call_decline.imageset/ic_call_decline@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_call_decline.imageset/ic_call_decline@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_call_min.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_call_min@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_call_min@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_call_min.imageset/ic_call_min@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_call_min.imageset/ic_call_min@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_call_mute.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_call_mute@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_call_mute@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_call_mute.imageset/ic_call_mute@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_call_mute.imageset/ic_call_mute@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_call_speaker_earpiece.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_call_speaker_earpiece@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_call_speaker_earpiece@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_call_speaker_earpiece.imageset/ic_call_speaker_earpiece@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_call_speaker_earpiece.imageset/ic_call_speaker_earpiece@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_call_speaker_phone.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_call_speaker_phone@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_call_speaker_phone@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_call_speaker_phone.imageset/ic_call_speaker_phone@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_call_speaker_phone.imageset/ic_call_speaker_phone@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_call_unmute.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_call_unmute@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_call_unmute@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_call_unmute.imageset/ic_call_unmute@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_call_unmute.imageset/ic_call_unmute@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_calling.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_calling@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_calling@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_calling.imageset/ic_calling@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_calling.imageset/ic_calling@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Call/ic_im_chat_call_over.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_im_chat_call_over@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_im_chat_call_over@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Call/ic_im_chat_call_over.imageset/ic_im_chat_call_over@2x.png


BIN
Lanu/Assets.xcassets/IM/Call/ic_im_chat_call_over.imageset/ic_im_chat_call_over@3x.png


+ 4 - 0
Lanu/Common/Extension/Double+Extension.swift

@@ -41,4 +41,8 @@ extension Double {
     var toDuration: Int {
         Int(rounded())
     }
+    
+    var timeCountDisplay: String {
+        toDuration.timeCountDisplay
+    }
 }

+ 19 - 0
Lanu/Common/Extension/Int+Extension.swift

@@ -0,0 +1,19 @@
+//
+//  Int+Extension.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/2/3.
+//
+
+import Foundation
+
+
+extension Int {
+    var timeCountDisplay: String {
+        if self > 3600 {
+            String(format: "%02d:%02d:%02d", self / 3600, self % 3600 / 60, self % 60)
+        } else {
+            String(format: "%02d:%02d", self / 60, self % 60)
+        }
+    }
+}

+ 3 - 0
Lanu/Common/Views/LNPopupView.swift

@@ -41,6 +41,9 @@ class LNPopupView: UIView {
         
         frame = parentView.bounds
         parentView.addSubview(self)
+        snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
         layoutIfNeeded()
         
         moveToShowupPosition()

+ 115 - 0
Lanu/Localizable.xcstrings

@@ -9111,6 +9111,121 @@
           }
         }
       }
+    },
+    "C00001" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Call Duration"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Durasi Panggilan"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "通话时长"
+          }
+        }
+      }
+    },
+    "C00002" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Unreachable"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Tidak terhubung"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "无法接通"
+          }
+        }
+      }
+    },
+    "C00003" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Cancel Call"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Batal Panggilan"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "取消通话"
+          }
+        }
+      }
+    },
+    "C00004" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Line busy"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Sedang sibuk"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "对方忙"
+          }
+        }
+      }
+    },
+    "C00005" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Reject Call"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Tolak Panggilan"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "拒绝通话"
+          }
+        }
+      }
     }
   },
   "version" : "1.1"

+ 156 - 7
Lanu/Manager/IM/LNIMManager.swift

@@ -6,18 +6,26 @@
 //
 
 import Foundation
+import TUICallEngine
 
 
 protocol LNIMManagerNotify {
     func onConversationListChanged()
     func onIMUserStatusChanged(uid: String, status: V2TIMUserStatusType)
+    
+    func onVoiceCallBegin()
+    func onVoiceCallEnd()
+    func onVoiceCallInfoChanged()
 }
 extension LNIMManagerNotify {
     func onConversationListChanged() {}
     func onIMUserStatusChanged(uid: String, status: V2TIMUserStatusType) {}
+    
+    func onVoiceCallBegin() { }
+    func onVoiceCallEnd() { }
+    func onVoiceCallInfoChanged() { }
 }
 
-
 extension String {
     var isImOfficialId: Bool {
         guard let intValue = Int(self) else { return false }
@@ -25,7 +33,6 @@ extension String {
     }
 }
 
-
 enum LNIMCustomErrorCode: Int {
     case inBlackList = 120001
     case userNotExist = 120002
@@ -35,6 +42,17 @@ enum LNIMOfficialIds: String {
     case officialMessage = "9998"
 }
 
+class LNIMVoiceCallInfo {
+    let uid: String
+    var isInCome = false
+    var beginTime: TimeInterval = 0
+    var isMute = false
+    var isSpeaker = false
+    
+    init(uid: String) {
+        self.uid = uid
+    }
+}
 
 class LNIMManager: NSObject {
     private static var appId: Int32 {
@@ -53,6 +71,10 @@ class LNIMManager: NSObject {
     private(set) var conversationList: [V2TIMConversation] = []
     private(set) var userStatus: [String: V2TIMUserStatusType] = [:]
     
+    private(set) var voiceCallAvailable = false
+    
+    private(set) var curCallInfo: LNIMVoiceCallInfo?
+    
     private override init() {
         super.init()
         _ = LNIMEmojiManager.shared
@@ -151,6 +173,76 @@ extension LNIMManager {
     }
 }
 
+extension LNIMManager {
+    func makeVoiceCall(uid: String) {
+        guard curCallInfo == nil else { return }
+        
+        curCallInfo = .init(uid: uid)
+        curCallInfo?.isInCome = false
+        
+        let panel = LNVoiceCallPanel()
+        panel.toCallOut(uid: uid)
+        panel.popup()
+        
+        let param = TUICallParams()
+        TUICallEngine.createInstance().call(userId: uid, callMediaType: .audio, params: param) {
+        } fail: { _, err in
+            showToast(err)
+        }
+    }
+    
+    func rejectVoiceCall() {
+        TUICallEngine.createInstance().reject { }
+        fail: { _, err in
+            showToast(err)
+        }
+    }
+    
+    func acceptVoiceCall() {
+        TUICallEngine.createInstance().accept { }
+        fail: { _, err in
+            showToast(err)
+        }
+    }
+    
+    func hangupVoiceCall() {
+        TUICallEngine.createInstance().hangup { }
+        fail: { _, err in
+            showToast(err)
+        }
+    }
+    
+    func switchVoiceCallMicrophone() {
+        if curCallInfo?.isMute == true {
+            TUICallEngine.createInstance().openMicrophone { [weak self] in
+                guard let self else { return }
+                curCallInfo?.isMute = false
+                LNEventDeliver.notifyEvent {
+                    ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged()
+                }
+            }
+            fail: { _, err in
+                showToast(err)
+            }
+        } else {
+            TUICallEngine.createInstance().closeMicrophone()
+            curCallInfo?.isMute = true
+            LNEventDeliver.notifyEvent {
+                ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged()
+            }
+        }
+    }
+    
+    func switchVoiceCallSpeakerType() {
+        let isSpeaker = curCallInfo?.isSpeaker == true
+        TUICallEngine.createInstance().selectAudioPlaybackDevice(isSpeaker ? .earpiece : .speakerphone)
+        curCallInfo?.isSpeaker = !isSpeaker
+        LNEventDeliver.notifyEvent {
+            ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged()
+        }
+    }
+}
+
 extension LNIMManager: V2TIMConversationListener {
     func onNewConversation(conversationList: [V2TIMConversation]!) {
         reloadConversationList()
@@ -190,6 +282,55 @@ extension LNIMManager: V2TIMSDKListener {
     }
 }
 
+extension LNIMManager: TUICallObserver {
+    func onCallReceived(callerId: String, calleeIdList: [String],
+                        groupId: String?, callMediaType: TUICallMediaType,
+                        userData: String?)
+    {
+        guard curCallInfo == nil else {
+            return
+        }
+        curCallInfo = .init(uid: callerId)
+        curCallInfo?.isInCome = true
+        
+        let panel = LNVoiceCallPanel()
+        panel.onCallIn(uid: callerId)
+        panel.popup()
+    }
+    
+    func onCallCancelled(callerId: String) {
+        curCallInfo = nil
+        LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() }
+    }
+    
+    func onCallBegin(roomId: TUIRoomId, callMediaType: TUICallMediaType, callRole: TUICallRole) {
+        curCallInfo?.beginTime = curTime
+        LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallBegin() }
+    }
+    
+    func onCallEnd(roomId: TUIRoomId, callMediaType: TUICallMediaType, callRole: TUICallRole, totalTime: Float) {
+        curCallInfo = nil
+        LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() }
+    }
+    
+    func onUserReject(userId: String) {
+        // 会同步回调 onCallCancelled
+//        curCallInfo = nil
+//        LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() }
+    }
+    
+    func onUserNoResponse(userId: String) {
+        // 会同步回调 onCallCancelled
+//        curCallInfo = nil
+//        LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() }
+    }
+    
+    func onUserLineBusy(userId: String) {
+        curCallInfo = nil
+        LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() }
+    }
+}
+
 extension LNIMManager: LNAccountManagerNotify {
     func onUserLogin() {
         // 初始化 SDK
@@ -204,19 +345,27 @@ extension LNIMManager: LNAccountManagerNotify {
             guard let self else { return }
             reloadConversationList()
         }
-        if V2TIMManager.sharedInstance().getLoginUser()?.isMyUid == true {
-            loginSuccessBlock()
-            return
-        }
         
         getIMSignToken { token in
             guard let token else { return }
-            V2TIMManager.sharedInstance().login(userID: myUid, userSig: token, succ: loginSuccessBlock)
+            if V2TIMManager.sharedInstance().getLoginUser()?.isMyUid == true {
+                loginSuccessBlock()
+            } else {
+                V2TIMManager.sharedInstance().login(userID: myUid, userSig: token, succ: loginSuccessBlock)
+            }
+            
+            TUICallEngine.createInstance().`init`(Self.appId, userId: myUid, userSig: token) { [weak self] in
+                guard let self else { return }
+                voiceCallAvailable = true
+                TUICallEngine.createInstance().addObserver(self)
+            } fail: { _, _ in
+            }
         }
     }
     
     func onUserLogout() {
         V2TIMManager.sharedInstance().logout(succ: nil)
+        TUICallEngine.destroyInstance()
         
         Self.shared = LNIMManager()
     }

+ 62 - 9
Lanu/Manager/IM/LNIMMessageData.swift

@@ -11,11 +11,13 @@ import AutoCodable
 
 enum LNIMMessageDataType {
     case none
-    case custom(LNIMMessageCustomType)
+    case official
     case system
     case text
     case image
     case voice
+    case order
+    case call(String)
 }
 
 
@@ -30,6 +32,43 @@ class LNIMCustomMessage: Decodable {
     var businessID: LNIMMessageCustomType = .none
 }
 
+@AutoCodable
+class LNIMVoiceCallMessage: Decodable {
+    var actionType: Int = 0
+    var businessID: Int = 0
+    var inviteID: String = ""
+    var data: String = ""
+    
+    private var decodedData: LNIMVoiceCallData?
+    
+    var callData: LNIMVoiceCallData? {
+        if let decodedData {
+            return decodedData
+        }
+        
+        guard let data = data.data(using: .utf8) else { return nil }
+        let decoder = JSONDecoder()
+        decoder.keyDecodingStrategy = .useDefaultKeys
+        decodedData = try? decoder.decode(LNIMVoiceCallData.self, from: data)
+        
+        return decodedData
+    }
+}
+
+@AutoCodable
+class LNIMVoiceCallData: Decodable {
+    var call_end: Int = 0
+    var data: LNIMVoiceCallExtraData?
+    
+    init() { }
+}
+
+@AutoCodable
+class LNIMVoiceCallExtraData: Decodable {
+    var excludeFromHistoryMessage: Bool = false
+    var cmd: String = ""
+}
+
 @AutoCodable
 class LNIMOfficialMessage: Decodable {
     var title: String = ""
@@ -60,7 +99,7 @@ private extension V2TIMElemType {
         case .ELEM_TYPE_TEXT: .text
         case .ELEM_TYPE_IMAGE: .image
         case .ELEM_TYPE_SOUND: .voice
-        case .ELEM_TYPE_CUSTOM: .custom(.none)
+        case .ELEM_TYPE_CUSTOM: .none
         default: .none
         }
     }
@@ -98,7 +137,7 @@ class LNIMMessageData: NSObject {
         
         guard let data = imMessage.customElem?.data else { return nil }
         let decoder = JSONDecoder()
-        decoder.keyDecodingStrategy = .convertFromSnakeCase // 处理蛇形命名
+        decoder.keyDecodingStrategy = .useDefaultKeys // 处理蛇形命名
         let result = try? decoder.decode(T.self, from: data)
         if let result {
             customMessage = result
@@ -144,12 +183,26 @@ extension LNIMMessageData {
         guard let data = imMessage.customElem?.data else { return }
         
         let decoder = JSONDecoder()
-        decoder.keyDecodingStrategy = .convertFromSnakeCase // 处理蛇形命名
-        let result = try? decoder.decode(LNIMCustomMessage.self, from: data)
-        if let businessID = result?.businessID {
-            type = .custom(businessID)
-        } else {
-            type = .custom(.none)
+        decoder.keyDecodingStrategy = .useDefaultKeys // 处理蛇形命名
+        
+        // 判断是否是通话消息
+        if let voiceCall = try? decoder.decode(LNIMVoiceCallMessage.self, from: data),
+           voiceCall.actionType != 0, voiceCall.businessID != 0 {
+            type = .call(voiceCall.inviteID)
+            return
+        }
+        
+        // 判断是否是自定义消息
+        if let result = try? decoder.decode(LNIMCustomMessage.self, from: data) {
+            switch result.businessID {
+            case .playmate_order:
+                type = .order // 订单消息
+            case .official_image_text:
+                type = .official // 官方消息
+            case .none: type = .none
+            }
+            return
         }
+        type = .none
     }
 }

+ 2 - 2
Lanu/Manager/IM/TUIUtils/V2TIMConversation+Extension.swift

@@ -36,9 +36,9 @@ extension V2TIMConversation {
         } else {
             if let lastMessage {
                 let imMessage = LNIMMessageData(imMessage: lastMessage)
-                if case .custom(.official_image_text) = imMessage.type {
+                if case .official = imMessage.type {
                     attrString.append(.init(string: .init(key: "A00019")))
-                } else if case .custom(.playmate_order) = imMessage.type {
+                } else if case .order = imMessage.type {
                     attrString.append(.init(string: .init(key: "A00020")))
                 } else {
                     let lastMsgStr = lastMessage.displayString

+ 1 - 1
Lanu/Manager/Network/LNHttpManager.swift

@@ -223,7 +223,7 @@ class LNHttpManager {
     ) {
         do {
             let decoder = JSONDecoder()
-            decoder.keyDecodingStrategy = .convertFromSnakeCase // 处理蛇形命名
+            decoder.keyDecodingStrategy = .useDefaultKeys // 处理蛇形命名
             let result = try decoder.decode(LNHttpResponse<T>.self, from: data)
             if result.code != 0 {
                 if result.code == LNHttpCommonErrorCode.beKicked.rawValue {

+ 2 - 2
Lanu/Views/Game/Join/Input/Example/LNJoinUsPhotoExamplePanel.swift

@@ -34,7 +34,7 @@ class LNJoinUsPhotoExamplePanel: LNPopupView {
             guard let image else { return }
             imageView.snp.makeConstraints { make in
                 make.height.equalTo(self.imageView.snp.width)
-                    .multipliedBy(image.size.height / image.size.width).priority(.medium)
+                    .multipliedBy(image.size.height / image.size.width).priority(.high)
             }
         }
         
@@ -57,7 +57,7 @@ extension LNJoinUsPhotoExamplePanel {
         
         imageView.layer.cornerRadius = 12
         imageView.clipsToBounds = true
-        imageView.contentMode = .scaleAspectFill
+        imageView.contentMode = .scaleAspectFit
         imageView.isUserInteractionEnabled = true
         imageView.onTap { [weak self] in
             guard let self else { return }

+ 1 - 2
Lanu/Views/Game/Skill/Edit/LNSkillFieldVoiceEditView.swift

@@ -115,8 +115,7 @@ extension LNSkillFieldVoiceEditView: LNVoiceRecorderNotify {
     func onRecordTaskDurationChanged(taskId: String, duration: Double, volumeRatio: Double) {
         guard recordTaskId == taskId else { return }
         
-        let intDuration = Int(duration)
-        recordDurationLabel.text = String(format: "%02d:%02d", intDuration / 60, intDuration % 60)
+        recordDurationLabel.text = duration.timeCountDisplay
     }
     
     func onRecordTaskRecording(taskId: String) {

+ 64 - 0
Lanu/Views/IM/Chat/Cells/LNIMChatCallMessageCell.swift

@@ -0,0 +1,64 @@
+//
+//  LNIMChatCallMessageCell.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/2/4.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+import TUICallEngine
+
+
+class LNIMChatCallMessageCell: LNIMChatBaseMessageCell {
+    private let contextLabel = UILabel()
+    
+    override func update(_ data: LNIMMessageData, viewModel: LNIMChatViewModel) {
+        super.update(data, viewModel: viewModel)
+        
+        guard let order: LNIMVoiceCallMessage = data.decodeCustomMessage() else { return }
+        guard let callData = order.callData else { return }
+        
+        if order.actionType == SignalingActionType.invite.rawValue,
+           let data = callData.data, data.cmd == "hangup" {
+            contextLabel.text = .init(key: "C00001") + " " + callData.call_end.timeCountDisplay
+        } else if order.actionType == SignalingActionType.cancel_Invite.rawValue {
+            contextLabel.text = .init(key: "C00003")
+        } else if order.actionType == SignalingActionType.reject_Invite.rawValue {
+            if callData.data?.cmd == "line_busy" {
+                contextLabel.text = .init(key: "C00004")
+            } else {
+                contextLabel.text = .init(key: "C00005")
+            }
+        } else if order.actionType == SignalingActionType.invite_Timeout.rawValue {
+            contextLabel.text = .init(key: "C00002")
+        } else {
+            contextLabel.text = ""
+        }
+    }
+    
+    override func setupViews() {
+        super.setupViews()
+        
+        let callIc = UIImageView()
+        callIc.image = .icImChatCallOver
+        container.addSubview(callIc)
+        callIc.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview()
+        }
+        
+        contextLabel.font = .body_l
+        contextLabel.textColor = .text_5
+        contextLabel.numberOfLines = 0
+        container.addSubview(contextLabel)
+        contextLabel.snp.makeConstraints { make in
+            make.verticalEdges.equalToSuperview()
+            make.trailing.equalToSuperview()
+            make.leading.equalTo(callIc.snp.trailing).offset(12)
+        }
+    }
+}
+
+

+ 2 - 2
Lanu/Views/IM/Chat/GameMate/LNIMChatGameMateOrderView.swift

@@ -80,7 +80,7 @@ extension LNIMChatGameMateOrderView {
     private func updateRemain() -> Bool {
         guard let curOrder else { return true }
         let remain = 3600 - (Int(curTime) - curOrder.createTime / 1_000)
-        replyRemainLabel.text = String(format: "%02d:%02d", remain/60, remain%60)
+        replyRemainLabel.text = remain.timeCountDisplay
         return remain <= 0
     }
 }
@@ -108,7 +108,7 @@ extension LNIMChatGameMateOrderView {
             titleArrow.isHidden = false
             replyView.isHidden = false
             let remain = 3600 - (Int(curTime) - order.createTime / 1_000)
-            replyRemainLabel.text = String(format: "%02d:%02d", remain/60, remain%60)
+            replyRemainLabel.text = remain.timeCountDisplay
             startCountDown()
             if !isCreator {
                 actionView.isHidden = false

+ 20 - 16
Lanu/Views/IM/Chat/LNIMChatViewController.swift

@@ -216,16 +216,15 @@ extension LNIMChatViewController: UITableViewDataSource, UITableViewDelegate {
             let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatVoiceMessageCell.className, for: indexPath) as! LNIMChatVoiceMessageCell
             cell.update(data, viewModel: viewModel)
             return cell
-        case .custom(let subType):
-            switch subType {
-            case .playmate_order:
-                let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatOrderMessageCell.className, for: indexPath) as! LNIMChatOrderMessageCell
-                cell.update(data, viewModel: viewModel)
-                return cell
-            default:
-                break
-            }
-        case .none:
+        case .order:
+            let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatOrderMessageCell.className, for: indexPath) as! LNIMChatOrderMessageCell
+            cell.update(data, viewModel: viewModel)
+            return cell
+        case .call:
+            let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatCallMessageCell.className, for: indexPath) as! LNIMChatCallMessageCell
+            cell.update(data, viewModel: viewModel)
+            return cell
+        case .none, .official:
             break
         }
         return tableView.dequeueReusableCell(withIdentifier: LNIMChatUnknownMessageCell.className, for: indexPath)
@@ -297,6 +296,7 @@ extension LNIMChatViewController {
         tableView.register(LNIMChatVoiceMessageCell.self, forCellReuseIdentifier: LNIMChatVoiceMessageCell.className)
         tableView.register(LNIMChatUnknownMessageCell.self, forCellReuseIdentifier: LNIMChatUnknownMessageCell.className)
         tableView.register(LNIMChatOrderMessageCell.self, forCellReuseIdentifier: LNIMChatOrderMessageCell.className)
+        tableView.register(LNIMChatCallMessageCell.self, forCellReuseIdentifier: LNIMChatCallMessageCell.className)
         tableView.dataSource = self
         tableView.delegate = self
         tableView.contentInset = .init(top: 8, left: 0, bottom: 0, right: 0)
@@ -371,12 +371,16 @@ extension LNIMChatViewController {
         }), for: .touchUpInside)
         menuStackView.addArrangedSubview(followButton)
         
-//        let phone = UIButton()
-//        phone.setImage(.icImChatPhone, for: .normal)
-//        phone.addAction(UIAction(handler: { [weak self] _ in
-//            guard let self else { return }
-//        }), for: .touchUpInside)
-//        stackView.addArrangedSubview(phone)
+        if LNIMManager.shared.voiceCallAvailable {
+            let phone = UIButton()
+            phone.setImage(.icImChatPhone, for: .normal)
+            phone.addAction(UIAction(handler: { [weak self] _ in
+                guard let self else { return }
+                guard !viewModel.userId.isEmpty else { return }
+                LNIMManager.shared.makeVoiceCall(uid: viewModel.userId)
+            }), for: .touchUpInside)
+            menuStackView.addArrangedSubview(phone)
+        }
         
         let more = UIButton()
         more.setImage(.icImChatMore, for: .normal)

+ 59 - 22
Lanu/Views/IM/Chat/ViewModel/LNIMChatViewModel.swift

@@ -29,9 +29,12 @@ class LNIMChatViewModel: NSObject {
     
     // 消息
     private var loading = false
+    private var topMessage: V2TIMMessage?
     private(set) var allMessage: [LNIMMessageData] = []
     private var lastData: Date? = nil
+    
     private var orderMessageCache: [String: LNIMMessageData] = [:]
+    private var callMessageCache: [String: LNIMMessageData] = [:]
     
     // 配置
     @Published
@@ -222,16 +225,9 @@ extension LNIMChatViewModel {
         }
         loading = true
         
-        let topMessage = allMessage.first {
-            if case .system = $0.type {
-                false
-            } else {
-                true
-            }
-        }
         V2TIMManager.sharedInstance().getC2CHistoryMessageList(
             userID: userId, count: 300,
-            lastMsg: topMessage?.imMessage)
+            lastMsg: topMessage)
         { [weak self] list in
             guard let self else { return }
             
@@ -242,6 +238,8 @@ extension LNIMChatViewModel {
                 return
             }
             
+            topMessage = list.last
+            
             let messages = transUIMsgFromIMMsg(messages: list)
             
             allMessage.insert(contentsOf: messages, at: 0)
@@ -281,7 +279,7 @@ extension LNIMChatViewModel {
         for message in messages.reversed() {
             let data = LNIMMessageData(imMessage: message)
             
-            if case .custom(.playmate_order) = data.type {
+            if case .order = data.type {
                 guard let orderMessage: LNIMOrderMessage = data.decodeCustomMessage() else {
                     continue // 解析失败,忽略
                 }
@@ -293,33 +291,71 @@ extension LNIMChatViewModel {
                         continue
                     } else {
                         // 订单的新消息
-                        if let index = allMessage.firstIndex(of: oldMessage) {
-                            // 移除旧的订单消息
-                            allMessage.remove(at: index)
-                            notifyMessageChanged(index: index, type: .delete, toBottom: false)
+                        removeOldMessage(oldMessage)
+                        if let index = datas.firstIndex(of: oldMessage) {
+                            datas.remove(at: index)
                         }
+                    }
+                }
+                orderMessageCache[orderMessage.orderId] = data
+            } else if case .call(let callId) = data.type {
+                guard let callMessage: LNIMVoiceCallMessage = data.decodeCustomMessage() else {
+                    continue // 解析失败,忽略
+                }
+                // 被标记不展示
+                if callMessage.callData?.data?.excludeFromHistoryMessage == true {
+                    continue
+                }
+                if let oldMessage = callMessageCache[callId] {
+                    // 存在旧的订单信息
+                    if (oldMessage.imMessage.timestamp?.timeIntervalSince1970 ?? 0)
+                        > (message.timestamp?.timeIntervalSince1970 ?? 0) {
+                        // 消息为旧的订单信息,忽略
+                        continue
+                    } else {
+                        // 订单的新消息
+                        removeOldMessage(oldMessage)
                         if let index = datas.firstIndex(of: oldMessage) {
                             datas.remove(at: index)
                         }
-                        
-                        // 生成新的订单消息
-                        orderMessageCache[orderMessage.orderId] = data
                     }
-                } else {
-                    orderMessageCache[orderMessage.orderId] = data
                 }
+                callMessageCache[callId] = data
             }
             
             datas.append(data)
-            if let dateMessage = buildDateMessageIfNeed(message: message) {
-                lastData = message.timestamp
-                datas.insert(dateMessage, at: datas.count - 1)
+        }
+        
+        datas.forEach {
+            guard let index = datas.firstIndex(of: $0) else { return }
+            
+            if let dateMessage = buildDateMessageIfNeed(message: $0.imMessage) {
+                datas.insert(dateMessage, at: index)
             }
         }
         
         return datas
     }
     
+    private func removeOldMessage(_ message: LNIMMessageData) {
+        guard let index = allMessage.firstIndex(of: message) else { return }
+        
+        // 移除旧的订单消息
+        var removeIndexs: [IndexPath] = []
+        allMessage.remove(at: index)
+        removeIndexs.append(.init(row: index, section: 0))
+        
+        if index - 1 >= 0 && index <= allMessage.count - 1,
+           case .system = allMessage[index - 1].type,
+           case .system = allMessage[index].type {
+            // 出现连续的两个时间戳消息,需要将前一个移除
+            allMessage.remove(at: index - 1)
+            removeIndexs.append(.init(row: index - 1, section: 0))
+        }
+        
+        notifyMessagesChanged(indexs: removeIndexs, type: .delete, toBottom: false)
+    }
+    
     private func buildDateMessageIfNeed(message: V2TIMMessage) -> LNIMMessageData? {
         guard let time = message.timestamp else { return nil }
             if let lastData,
@@ -327,6 +363,7 @@ extension LNIMChatViewModel {
             return nil
         }
         
+        lastData = message.timestamp
         return LNIMMessageData.buildDateMessage(date: time)
     }
 }
@@ -344,7 +381,7 @@ extension LNIMChatViewModel: V2TIMAdvancedMsgListener {
         
         var isOrderMessage = false
         for item in list {
-            if case .custom(.playmate_order) = item.type {
+            if case .order = item.type {
                 isOrderMessage = true
                 break
             }

+ 167 - 0
Lanu/Views/IM/Chat/VoiceCall/LNVoiceCallFloatingView.swift

@@ -0,0 +1,167 @@
+//
+//  LNVoiceCallFloatingView.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/2/3.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNVoiceCallFloatingView: UIView {
+    private let stackView = UIStackView()
+    private let stateIc = UIImageView()
+    private let durationLabel = UILabel()
+    
+    private var timer: Timer?
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+        setupGesture()
+        
+        LNEventDeliver.addObserver(self)
+        
+        if let callInfo = LNIMManager.shared.curCallInfo,
+           callInfo.beginTime > 0 {
+            updateCallDuration()
+            startTimer()
+        }
+    }
+    
+    func show() {
+        UIView.appKeyWindow?.addSubview(self)
+        snp.makeConstraints { make in
+            make.trailing.equalToSuperview().offset(-16)
+            make.bottom.equalToSuperview().offset(-100)
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNVoiceCallFloatingView {
+    private func dismiss() {
+        removeFromSuperview()
+    }
+    
+    private func updateCallDuration() {
+        guard let callInfo = LNIMManager.shared.curCallInfo else { return }
+        let duration = curTime - callInfo.beginTime
+        durationLabel.text = duration.timeCountDisplay
+        durationLabel.isHidden = false
+    }
+    
+    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 LNVoiceCallFloatingView {
+    @objc
+    private func handlePan(_ ges: UIPanGestureRecognizer) {
+        let location = ges.location(in: superview)
+        
+        switch ges.state {
+        case .began:
+            break
+        case .changed:
+            center = location
+            break
+        default:
+            updatePosition(animated: true)
+            break
+        }
+    }
+}
+
+extension LNVoiceCallFloatingView {
+    private func updatePosition(animated: Bool) {
+        guard let superview else { return }
+        let movement = { [weak self] in
+            guard let self else { return }
+            
+            let y = center.y.bounded(min: 160, max: superview.bounds.height - 160)
+            if center.x > superview.bounds.width * 0.5 {
+                center = .init(x: superview.bounds.width - bounds.width * 0.5 - 16, y: y)
+            } else {
+                center = .init(x: bounds.width * 0.5 + 16, y: y)
+            }
+        }
+        if animated {
+            UIView.animate(withDuration: 0.25, animations: movement)
+        } else {
+            movement()
+        }
+    }
+    
+    private func setupViews() {
+        backgroundColor = .fill
+        layer.cornerRadius = 12
+        snp.makeConstraints { make in
+            make.width.height.equalTo(67)
+        }
+        
+        stackView.axis = .vertical
+        stackView.spacing = 8
+        stackView.alignment = .center
+        addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.center.equalToSuperview()
+        }
+        
+        stateIc.image = .icCalling
+        stackView.addArrangedSubview(stateIc)
+        
+        durationLabel.isHidden = true
+        durationLabel.font = .heading_h5
+        durationLabel.textColor = .text_6
+        stackView.addArrangedSubview(durationLabel)
+    }
+    
+    private func setupGesture() {
+        let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
+        addGestureRecognizer(pan)
+        
+        onTap { [weak self] in
+            guard let self else { return }
+            dismiss()
+            
+            let panel = LNVoiceCallPanel()
+            panel.resume()
+            panel.popup()
+        }
+    }
+}
+
+extension LNVoiceCallFloatingView: LNIMManagerNotify {
+    func onVoiceCallEnd() {
+        dismiss()
+    }
+    
+    func onVoiceCallBegin() {
+        updateCallDuration()
+        startTimer()
+    }
+}

+ 451 - 0
Lanu/Views/IM/Chat/VoiceCall/LNVoiceCallPanel.swift

@@ -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
+

+ 2 - 2
Lanu/Views/IM/Notify/LNIMOfficialMessageViewController.swift

@@ -116,8 +116,8 @@ extension LNIMOfficialMessageViewController: UITableViewDataSource, UITableViewD
             let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatSystemMessageCell.className, for: indexPath) as! LNIMChatSystemMessageCell
             cell.update(data)
             return cell
-        case .custom(let subType):
-            if case .official_image_text = subType, let message: LNIMOfficialMessage = data.decodeCustomMessage() {
+        case .official:
+            if let message: LNIMOfficialMessage = data.decodeCustomMessage() {
                 let cell = tableView.dequeueReusableCell(withIdentifier: LNIMOfficialMessageCell.className, for: indexPath) as! LNIMOfficialMessageCell
                 cell.update(message)
                 return cell

+ 1 - 1
Lanu/Views/Order/Detail/LNOrderDetailViewController.swift

@@ -103,7 +103,7 @@ extension LNOrderDetailViewController {
     private func updateRemain() -> Bool {
         guard let curDetail else { return true }
         let remain = 3600 - (Int(curTime) - curDetail.orderInfo.createTime / 1_000)
-        let countDownText = String(format: "%02d:%02d", remain/60, remain%60)
+        let countDownText = remain.timeCountDisplay
         
         let text: String = .init(key: "A00134", countDownText)
         let attr = NSMutableAttributedString(string: text)

+ 4 - 7
Lanu/Views/Profile/Edit/LNEditVoicePanel.swift

@@ -107,8 +107,7 @@ extension LNEditVoicePanel {
         }
         curUrl = url
         curDuration = duration
-        let intDuration = duration.toDuration
-        editDurationLabel.text = String(format: "%02d:%02d", intDuration / 60, intDuration % 60)
+        editDurationLabel.text = duration.timeCountDisplay
         curState = .edit
     }
 }
@@ -118,7 +117,7 @@ extension LNEditVoicePanel: LNVoicePlayerNotify {
         if !editView.isHidden {
             guard curUrl?.path == path else { return }
             let remain = Int(total - cur)
-            editDurationLabel.text = String(format: "%02d:%02d", remain / 60, remain % 60)
+            editDurationLabel.text = remain.timeCountDisplay
         } else if !displayView.isHidden {
             guard path == myUserInfo.voiceBar else { return }
             playDurationLabel.text = (total - cur).durationDisplay
@@ -129,8 +128,7 @@ extension LNEditVoicePanel: LNVoicePlayerNotify {
         if !editView.isHidden {
             guard curUrl?.path == path else { return }
             guard let curDuration else { return }
-            let intDuration = curDuration.toDuration
-            editDurationLabel.text = String(format: "%02d:%02d", intDuration / 60, intDuration % 60)
+            editDurationLabel.text = curDuration.timeCountDisplay
             editPlayButton.setImage(.icVoiceEditPlay, for: .normal)
         } else if !displayView.isHidden {
             guard path == myUserInfo.voiceBar else { return }
@@ -156,8 +154,7 @@ extension LNEditVoicePanel: LNVoiceRecorderNotify {
     func onRecordTaskDurationChanged(taskId: String, duration: Double, volumeRatio: Double) {
         guard recordTaskId == taskId else { return }
         
-        let intDuration = Int(duration)
-        recordDurationLabel.text = String(format: "%02d:%02d", intDuration / 60, intDuration % 60)
+        recordDurationLabel.text = duration.timeCountDisplay
     }
     
     func onRecordTaskRecording(taskId: String) {

+ 3 - 0
Podfile

@@ -11,6 +11,9 @@ target 'Gami' do
   pod 'TIMCommon', :path => "./ThirdParty/TUIKit/TIMCommon"
   pod 'TUIChat', :path => "./ThirdParty/TUIKit/TUIChat"
   
+  pod 'TUICallEngine'
+  pod 'TIMPush'
+  
   pod 'DoraemonKit', :configurations => ['Debug']
 
 end

+ 19 - 1
Podfile.lock

@@ -22,13 +22,25 @@ PODS:
     - TIMCommon/ImSDK_Plus (= 1.0.0)
   - TIMCommon/ImSDK_Plus (1.0.0):
     - TXIMSDK_Plus_iOS_XCFramework
+  - TIMPush (8.7.7201):
+    - TXIMSDK_Plus_iOS_XCFramework (>= 8.7.7201)
+  - TUICallEngine (2.7.0.1151):
+    - TUICallEngine/TRTC (= 2.7.0.1151)
+  - TUICallEngine/TRTC (2.7.0.1151):
+    - TXIMSDK_Plus_iOS_XCFramework (>= 8.3.6498)
+    - TXLiteAVSDK_TRTC (>= 11.7.15343)
   - TUIChat (1.0.0):
     - TIMCommon
   - TXIMSDK_Plus_iOS_XCFramework (8.7.7201)
+  - TXLiteAVSDK_TRTC (12.8.19666):
+    - TXLiteAVSDK_TRTC/TRTC (= 12.8.19666)
+  - TXLiteAVSDK_TRTC/TRTC (12.8.19666)
 
 DEPENDENCIES:
   - DoraemonKit
   - TIMCommon (from `./ThirdParty/TUIKit/TIMCommon`)
+  - TIMPush
+  - TUICallEngine
   - TUIChat (from `./ThirdParty/TUIKit/TUIChat`)
 
 SPEC REPOS:
@@ -36,7 +48,10 @@ SPEC REPOS:
     - DoraemonKit
     - FMDB
     - GCDWebServer
+    - TIMPush
+    - TUICallEngine
     - TXIMSDK_Plus_iOS_XCFramework
+    - TXLiteAVSDK_TRTC
 
 EXTERNAL SOURCES:
   TIMCommon:
@@ -49,9 +64,12 @@ SPEC CHECKSUMS:
   FMDB: 728731dd336af3936ce00f91d9d8495f5718a0e6
   GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4
   TIMCommon: 36dad82b29c87b6cfd7eb5251c44db7d03598267
+  TIMPush: 4f4fa655697c4106309054d0b50a485e642b4f80
+  TUICallEngine: f00a90ab800d6008c253bb2fc6200cd21ee1133a
   TUIChat: 696bca6e2a6cfd2bc22f624425b425b68bd9506c
   TXIMSDK_Plus_iOS_XCFramework: 3b435eae84c639f35ae8dc9c8b92c399a8b0a67f
+  TXLiteAVSDK_TRTC: b576b0c6a477fa98b5d2b33be63fa9aa7c41f0eb
 
-PODFILE CHECKSUM: 55268eb8555e2ec448595c4dc09fe7a249108722
+PODFILE CHECKSUM: f118fc1e373cb1d93050835729cbe48315a635bf
 
 COCOAPODS: 1.16.2