Ver código fonte

feat: 解决Chat,Call依赖冲突

DoggyZhang 1 mês atrás
pai
commit
1291061b76
100 arquivos alterados com 11657 adições e 3 exclusões
  1. 2 0
      app/build.gradle
  2. 2 1
      app/dependencies/releaseRuntimeClasspath.txt
  3. 0 0
      app/src/main/res/anim/common_slide_in_from_bottom.xml
  4. 0 0
      app/src/main/res/anim/common_slide_out_bottom.xml
  5. 1 0
      app/src/main/res/values/no_translate_strings.xml
  6. 2 2
      app/src/main/res/values/styles.xml
  7. 16 0
      frame/atomic_x/.gitignore
  8. 66 0
      frame/atomic_x/build.gradle
  9. 21 0
      frame/atomic_x/proguard-rules.pro
  10. 19 0
      frame/atomic_x/src/main/AndroidManifest.xml
  11. 263 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/CallView.kt
  12. 7 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/CallViewFunction.kt
  13. 15 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/Constants.kt
  14. 10 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/utils/CallUtils.kt
  15. 232 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/utils/ImageResourceCache.kt
  16. 77 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/utils/Logger.kt
  17. 52 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/utils/PermissionRequest.kt
  18. 33 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/widget/ControlButton.kt
  19. 189 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/aisubtitle/AISubtitle.kt
  20. 43 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/AudioAndVideoCalleeWaitingView.kt
  21. 124 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/AudioCallerWaitingAndAcceptedView.kt
  22. 76 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/MultiCallControlsView.kt
  23. 79 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/SingleCallControlsView.kt
  24. 378 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/VideoCallerAndCalleeAcceptedView.kt
  25. 150 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/VideoCallerWaitingView.kt
  26. 129 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/hint/HintView.kt
  27. 52 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/hint/TimerView.kt
  28. 119 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/multi/MultiCallWaitingView.kt
  29. 34 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/service/MusicServiceFactory.kt
  30. 25 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/CallbacksDefine.kt
  31. 1205 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/KaraokeStore.kt
  32. 7 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/MusicCatalogService.kt
  33. 21 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/utils/KaraokeTypes.kt
  34. 89 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/utils/LyricsFileReader.kt
  35. 443 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/KaraokeControlView.kt
  36. 405 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/KaraokeFloatingView.kt
  37. 145 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/KaraokeSettingPanel.kt
  38. 220 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/LyricView.kt
  39. 345 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/PitchView.kt
  40. 196 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/SongRequestPanel.kt
  41. 165 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/adapter/KaraokeOrderedListAdapter.kt
  42. 107 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/adapter/KaraokeSongListAdapter.kt
  43. 24 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/pictureinpicture/PictureInPictureStore.kt
  44. 145 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/ThemeStore.kt
  45. 678 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/tokens/ColorTokens.kt
  46. 34 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/tokens/DesignTokenSet.kt
  47. 36 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/tokens/FontTokens.kt
  48. 41 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/tokens/ShadowTokens.kt
  49. 258 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/utils/ColorAlgorithm.kt
  50. 65 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/utils/ThemePersistUtil.kt
  51. 481 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/alertdialog/AtomicAlertDialog.kt
  52. 124 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/alertdialog/README.md
  53. 483 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/avatar/AtomicAvatar.kt
  54. 460 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/avatar/README.md
  55. 579 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/button/AtomicButton.kt
  56. 178 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/button/README.md
  57. 242 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/label/AtomicLabel.kt
  58. 523 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/label/README.md
  59. 441 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/popover/AtomicPopover.kt
  60. 94 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/popover/README.md
  61. 183 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/toast/AtomicToast.kt
  62. 237 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/toast/README.md
  63. 337 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/utils/BlurUtils.kt
  64. 22 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/utils/DisplayUtil.kt
  65. 185 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/utils/ImageLoader.kt
  66. 69 0
      frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/utils/ImageOptions.kt
  67. 5 0
      frame/atomic_x/src/main/res-advance-setting/color/switch_thumb_bg.xml
  68. 5 0
      frame/atomic_x/src/main/res-advance-setting/color/switch_track_bg.xml
  69. 5 0
      frame/atomic_x/src/main/res-advance-setting/drawable/bg_advance_setting_button.xml
  70. 6 0
      frame/atomic_x/src/main/res-advance-setting/drawable/bg_advance_setting_panel.xml
  71. 15 0
      frame/atomic_x/src/main/res-advance-setting/drawable/switch_thumb.xml
  72. 17 0
      frame/atomic_x/src/main/res-advance-setting/drawable/switch_track.xml
  73. 16 0
      frame/atomic_x/src/main/res-advance-setting/layout/advance_setting_button.xml
  74. 53 0
      frame/atomic_x/src/main/res-advance-setting/layout/advance_setting_panel.xml
  75. 24 0
      frame/atomic_x/src/main/res-advance-setting/layout/item_advance_setting.xml
  76. 8 0
      frame/atomic_x/src/main/res-advance-setting/values/colors.xml
  77. 14 0
      frame/atomic_x/src/main/res-advance-setting/values/strings.xml
  78. 6 0
      frame/atomic_x/src/main/res-advance-setting/values/themes.xml
  79. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_check_box_group_selected.png
  80. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_group_select_disable.png
  81. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_hangup_loading.gif
  82. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_add_user_black.png
  83. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_audio_input.png
  84. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_audio_route_picker.png
  85. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_avatar.png
  86. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_back.png
  87. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_blur_background_accept.png
  88. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_blur_disable.png
  89. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_blur_enable.png
  90. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_camera_disable.png
  91. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_camera_enable.png
  92. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_dialing.png
  93. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_dialing_pressed.png
  94. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_float.png
  95. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_float_button.png
  96. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_handsfree_disable.png
  97. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_handsfree_enable.png
  98. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_hangup.png
  99. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_hangup_pressed.png
  100. BIN
      frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_loading.gif

+ 2 - 0
app/build.gradle

@@ -229,6 +229,7 @@ android {
             ':module:share',
             ':module:image',
             ':module:joinus',
+            ':module:call',
     ]
     buildFeatures {
         viewBinding true
@@ -496,6 +497,7 @@ dependencies {
     implementation libs.pinyin
 
     api libs.tencent.tui.core
+    api libs.tencent.uikit.common
     api project(":timcommon")
     api project(":tuichat")
     implementation project(":tuicontact")

+ 2 - 1
app/dependencies/releaseRuntimeClasspath.txt

@@ -80,7 +80,7 @@ androidx.print:print:1.0.0
 androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
 androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
 androidx.profileinstaller:profileinstaller:1.3.1
-androidx.recyclerview:recyclerview:1.3.1
+androidx.recyclerview:recyclerview:1.3.2
 androidx.resourceinspection:resourceinspection-annotation:1.0.1
 androidx.room:room-common:2.6.1
 androidx.room:room-ktx:2.6.1
@@ -269,6 +269,7 @@ io.github.scwang90:refresh-header-material:3.0.0-alpha
 io.github.scwang90:refresh-layout-kernel:3.0.0-alpha
 io.reactivex.rxjava3:rxandroid:3.0.0
 io.reactivex.rxjava3:rxjava:3.0.4
+io.trtc.uikit:common:3.3.0.1194
 javax.inject:javax.inject:1
 org.checkerframework:checker-qual:3.43.0
 org.conscrypt:conscrypt-android:2.5.3

+ 0 - 0
app/src/main/res/anim/slide_in_from_bottom.xml → app/src/main/res/anim/common_slide_in_from_bottom.xml


+ 0 - 0
app/src/main/res/anim/slide_out_bottom.xml → app/src/main/res/anim/common_slide_out_bottom.xml


+ 1 - 0
app/src/main/res/values/no_translate_strings.xml

@@ -13,5 +13,6 @@
     <string name="module_share" translatable="false">share</string>
     <string name="module_image" translatable="false">image</string>
     <string name="module_joinus" translatable="false">activity</string>
+    <string name="module_call" translatable="false">call</string>
 
 </resources>

+ 2 - 2
app/src/main/res/values/styles.xml

@@ -17,8 +17,8 @@
     </style>
 
     <style name="BottomDialog.Window" parent="@android:style/Animation.Activity">
-        <item name="android:windowEnterAnimation">@anim/slide_in_from_bottom</item>
-        <item name="android:windowExitAnimation">@anim/slide_out_bottom</item>
+        <item name="android:windowEnterAnimation">@anim/common_slide_in_from_bottom</item>
+        <item name="android:windowExitAnimation">@anim/common_slide_out_bottom</item>
     </style>
 
     <style name="AppCompatLightDialog" parent="@style/Theme.AppCompat.Light.Dialog">

+ 16 - 0
frame/atomic_x/.gitignore

@@ -0,0 +1,16 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+.kotlin

+ 66 - 0
frame/atomic_x/build.gradle

@@ -0,0 +1,66 @@
+plugins {
+    id 'com.android.library'
+    id 'org.jetbrains.kotlin.android'
+}
+
+
+android {
+    namespace 'io.trtc.tuikit.atomicx'
+    compileSdk 34
+
+    defaultConfig {
+        minSdk 21
+        versionName "1.0"
+        versionCode 1
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+        }
+    }
+
+    def javaVersion = JavaVersion.VERSION_1_8
+    def gradleMajorVersion = gradle.gradleVersion.tokenize('.')[0].toInteger()
+    if (gradleMajorVersion >= 8) {
+        javaVersion = JavaVersion.VERSION_17
+    }
+
+    compileOptions {
+        sourceCompatibility javaVersion
+        targetCompatibility javaVersion
+    }
+
+    kotlinOptions {
+        jvmTarget = javaVersion
+    }
+
+
+    sourceSets {
+        main {
+            res.srcDirs = [
+                    'src/main/res',
+                    'src/main/res-karaoke',
+                    'src/main/res-callview',
+            ]
+        }
+    }
+}
+
+dependencies {
+    implementation libs.androidx.appcompat
+    implementation libs.androidx.constraint.layout
+    implementation libs.androidx.recyclerview
+
+    api libs.android.material
+    api libs.androidx.core.ktx
+    api libs.kotlinx.coroutines.android
+
+    api libs.gson
+    api libs.glide
+
+    api libs.tencent.atomic.x
+    api libs.tencent.uikit.common
+    api libs.tencent.tui.core
+}

+ 21 - 0
frame/atomic_x/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 19 - 0
frame/atomic_x/src/main/AndroidManifest.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission
+        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+        android:maxSdkVersion="28" />
+    <uses-permission
+        android:name="android.permission.READ_EXTERNAL_STORAGE"
+        android:maxSdkVersion="32" />
+    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+    <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
+    <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
+
+</manifest>

+ 263 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/CallView.kt

@@ -0,0 +1,263 @@
+package io.trtc.tuikit.atomicx.callview
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import androidx.constraintlayout.widget.ConstraintLayout
+import io.trtc.tuikit.atomicx.callview.core.CallViewFunction
+import io.trtc.tuikit.atomicxcore.api.call.CallParticipantInfo
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.view.CallCoreView
+import io.trtc.tuikit.atomicxcore.api.view.CallLayoutTemplate
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import com.trtc.tuikit.common.util.ScreenUtil.dip2px
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.callview.core.common.utils.CallUtils
+import io.trtc.tuikit.atomicx.callview.core.common.utils.ImageResourceCache
+import io.trtc.tuikit.atomicx.callview.public.multi.MultiCallWaitingView
+import io.trtc.tuikit.atomicx.callview.public.controls.MultiCallControlsView
+import io.trtc.tuikit.atomicx.callview.public.controls.SingleCallControlsView
+import io.trtc.tuikit.atomicx.callview.public.hint.HintView
+import io.trtc.tuikit.atomicx.callview.public.hint.TimerView
+import io.trtc.tuikit.atomicxcore.api.call.CallParticipantStatus
+import io.trtc.tuikit.atomicxcore.api.device.NetworkQuality
+import io.trtc.tuikit.atomicxcore.api.view.VolumeLevel
+import kotlinx.coroutines.supervisorScope
+import java.io.File
+import java.util.concurrent.atomic.AtomicInteger
+
+class CallView @JvmOverloads constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0
+) : ConstraintLayout(context, attrs, defStyleAttr), CallViewFunction {
+    private data class ParticipantAvatarInfo(
+        var originalUrl: String,
+        var cachedPath: String?
+    )
+    
+    private var callMainView: CallCoreView? = null
+    private var subscribeStateJob: Job? = null
+    private val participantAvatarInfoMap: MutableMap<String, ParticipantAvatarInfo> = mutableMapOf()
+    private val imageResourceCache = ImageResourceCache(context)
+
+    private var layoutFunction: FrameLayout? = null
+    private var layoutTimer: FrameLayout? = null
+    private var layoutCallHint: FrameLayout? = null
+    private var multiCallWaitingViewContainer: LinearLayout? = null
+
+    init {
+        initView()
+        setIconResourcePath()
+    }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        addFunctionLayout()
+        updateWaitingView()
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            supervisorScope {
+                launch { observeSelfInfo() }
+                launch { observeParticipantInfo() }
+            }
+        }
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        callMainView?.removeAllViews()
+        subscribeStateJob?.cancel()
+    }
+
+    private suspend fun observeSelfInfo() {
+        CallStore.shared.observerState.selfInfo.collect { selfInfo ->
+            if (selfInfo.status == CallParticipantStatus.Accept && callMainView?.visibility == GONE) {
+                updateWaitingView()
+            }
+        }
+    }
+
+    private suspend fun observeParticipantInfo() {
+        CallStore.shared.observerState.allParticipants.collect { allParticipants ->
+            updateParticipantsAvatars(allParticipants)
+        }
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.callview_root_view, this, true)
+        multiCallWaitingViewContainer = findViewById(R.id.ll_callee_waiting_view)
+        layoutFunction = findViewById(R.id.rl_layout_function)
+        layoutTimer = findViewById(R.id.rl_layout_call_time)
+        layoutCallHint = findViewById(R.id.rl_layout_call_hint)
+        callMainView = CallCoreView(context)
+        val layoutParams = ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.MATCH_PARENT)
+        this.addView(callMainView, 0, layoutParams)
+    }
+
+    private fun updateWaitingView() {
+        val selfUser = CallStore.shared.observerState.selfInfo.value
+        if (CallParticipantStatus.Waiting == selfUser.status && !CallUtils.isCaller(selfUser.id) && isMultiCall()) {
+            multiCallWaitingViewContainer?.addView(MultiCallWaitingView(context))
+            multiCallWaitingViewContainer?.visibility = VISIBLE
+            callMainView?.visibility = GONE
+            layoutCallHint?.visibility = GONE
+            layoutTimer?.visibility = GONE
+        } else {
+            multiCallWaitingViewContainer?.visibility = GONE
+            callMainView?.visibility = VISIBLE
+            layoutCallHint?.visibility = VISIBLE
+            layoutTimer?.visibility = VISIBLE
+        }
+    }
+
+    private fun setIconResourcePath() {
+        val volumeLevelIcons = mapOf(
+            VolumeLevel.Mute to imageResourceCache.getDrawablePath(R.drawable.callview_ic_self_mute),
+            VolumeLevel.Low to imageResourceCache.getDrawablePath(R.drawable.callview_ic_audio_input),
+            VolumeLevel.Medium to imageResourceCache.getDrawablePath(R.drawable.callview_ic_audio_input),
+            VolumeLevel.High to imageResourceCache.getDrawablePath(R.drawable.callview_ic_audio_input),
+            VolumeLevel.Peak to imageResourceCache.getDrawablePath(R.drawable.callview_ic_audio_input),
+        )
+        callMainView?.setVolumeLevelIcons(volumeLevelIcons)
+
+        val networkQualityIcons = mapOf(
+            NetworkQuality.BAD to imageResourceCache.getDrawablePath(R.drawable.callview_ic_network_bad),
+            NetworkQuality.VERY_BAD to imageResourceCache.getDrawablePath(R.drawable.callview_ic_network_bad)
+        )
+        callMainView?.setNetworkQualityIcons(networkQualityIcons)
+        callMainView?.setWaitingAnimation(imageResourceCache.getDrawablePath(R.drawable.callview_ic_loading))
+    }
+
+    private fun addFunctionLayout() {
+        if (isMultiCall()) {
+            addMultiFunctionLayout()
+        } else {
+            addSingleFunctionLayout()
+        }
+    }
+
+    private fun addMultiFunctionLayout() {
+        layoutFunction?.addView(MultiCallControlsView(context))
+        layoutTimer?.addView(TimerView(context))
+        layoutCallHint?.addView(HintView(context))
+    }
+
+    private fun addSingleFunctionLayout() {
+        layoutFunction?.addView(SingleCallControlsView(context))
+        layoutTimer?.addView(TimerView(context))
+        layoutCallHint?.addView(HintView(context))
+    }
+
+    private fun updateParticipantsAvatars(participants: Collection<CallParticipantInfo>) {
+        val participantsToUpdate = mutableListOf<Pair<String, String>>()
+        val currentParticipantIds = participants.map { it.id }.toSet()
+        val removedIds = participantAvatarInfoMap.keys.filter { it !in currentParticipantIds }
+        val hasRemovedParticipants = removedIds.isNotEmpty()
+        removedIds.forEach { id ->
+            participantAvatarInfoMap.remove(id)
+        }
+        for (participant in participants) {
+            val participantId = participant.id
+            val currentAvatarUrl = participant.avatarUrl ?: ""
+            val existingInfo = participantAvatarInfoMap[participantId]
+            if (existingInfo?.originalUrl != currentAvatarUrl) {
+                participantsToUpdate.add(participantId to currentAvatarUrl)
+                participantAvatarInfoMap[participantId] = ParticipantAvatarInfo(
+                    originalUrl = currentAvatarUrl,
+                    cachedPath = existingInfo?.cachedPath
+                )
+            }
+        }
+        
+        if (participantsToUpdate.isEmpty()) {
+            if (hasRemovedParticipants) {
+                val avatarMap = buildAvatarPathMap()
+                callMainView?.setParticipantAvatars(avatarMap)
+            }
+            return
+        }
+        
+        val completedCount = AtomicInteger(0)
+        val totalCount = participantsToUpdate.size
+        
+        for ((participantId, avatarUrl) in participantsToUpdate) {
+            imageResourceCache.cacheNetworkImage(avatarUrl) { cachedPath ->
+                synchronized(participantAvatarInfoMap) {
+                    val info = participantAvatarInfoMap[participantId]
+                    if (info != null) {
+                        if (cachedPath != null) {
+                            info.cachedPath = File(cachedPath).absolutePath
+                        } else {
+                            val defaultAvatarPath = imageResourceCache.getDefaultAvatarPath(R.drawable.callview_ic_avatar)
+                            info.cachedPath = defaultAvatarPath
+                            if (defaultAvatarPath == null) {
+                                participantAvatarInfoMap.remove(participantId)
+                            }
+                        }
+                    }
+                    if (completedCount.incrementAndGet() == totalCount) {
+                        val avatarMap = buildAvatarPathMap()
+                        callMainView?.setParticipantAvatars(avatarMap)
+                    }
+                }
+            }
+        }
+    }
+
+    private fun buildAvatarPathMap(): Map<String, String> {
+        return participantAvatarInfoMap
+            .filter { it.value.cachedPath != null }
+            .mapValues { it.value.cachedPath!! }
+    }
+
+    override fun setLayoutTemplate(template: CallLayoutTemplate) {
+        val isPipView = template == CallLayoutTemplate.Pip
+        layoutFunction?.visibility = if (isPipView) GONE else VISIBLE
+        layoutCallHint?.visibility = if (isPipView) GONE else VISIBLE
+        layoutTimer?.visibility = if (isPipView) GONE else VISIBLE
+        multiCallWaitingViewContainer?.visibility = if (isPipView) GONE else VISIBLE
+        callMainView?.visibility = VISIBLE
+        updateCallCoreViewTopMargin(template)
+        callMainView?.setLayoutTemplate(template)
+    }
+
+    private fun updateCallCoreViewTopMargin(template: CallLayoutTemplate) {
+        val marginTop = if (template == CallLayoutTemplate.Grid) {
+            getStatusBarHeight() + dip2px(GRID_VIDEO_CONTAINER_MARGIN_TOP_DP)
+        } else {
+            0
+        }
+        val layoutParams = callMainView?.layoutParams as? ConstraintLayout.LayoutParams
+        layoutParams?.let {
+            it.topMargin = marginTop
+            callMainView?.layoutParams = it
+        }
+    }
+
+    private fun getStatusBarHeight(): Int {
+        var statusBarHeight = 0
+        val resourceId = this.resources.getIdentifier(STATUS_BAR_HEIGHT, DIMEN, ANDROID)
+        if (resourceId > 0) {
+            statusBarHeight = this.resources.getDimensionPixelSize(resourceId)
+        }
+        return statusBarHeight
+    }
+
+    private fun isMultiCall(): Boolean {
+        val inviteeIdListSize = CallStore.shared.observerState.activeCall.value.inviteeIds.size
+        val chatGroupId = CallStore.shared.observerState.activeCall.value.chatGroupId
+        return chatGroupId.isNotEmpty() || inviteeIdListSize > 1
+    }
+
+    companion object {
+        private const val GRID_VIDEO_CONTAINER_MARGIN_TOP_DP = 45f
+        private const val STATUS_BAR_HEIGHT = "status_bar_height"
+        private const val DIMEN = "dimen"
+        private const val ANDROID = "android"
+    }
+}

+ 7 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/CallViewFunction.kt

@@ -0,0 +1,7 @@
+package io.trtc.tuikit.atomicx.callview.core
+
+import io.trtc.tuikit.atomicxcore.api.view.CallLayoutTemplate
+
+interface CallViewFunction {
+    fun setLayoutTemplate(template: CallLayoutTemplate)
+}

+ 15 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/Constants.kt

@@ -0,0 +1,15 @@
+package io.trtc.tuikit.atomicx.callview.core.common
+
+object Constants {
+    const val MIN_AUDIO_VOLUME = 10
+    const val BLUR_LEVEL_HIGH = 3
+    const val BLUR_LEVEL_CLOSE = 0
+    const val AI_TRANSLATION_ROBOT = "TAI_Robot"
+    enum class ControlButton {
+        Microphone,
+        AudioPlaybackDevice,
+        Camera,
+        SwitchCamera,
+        InviteUser
+    }
+}

+ 10 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/utils/CallUtils.kt

@@ -0,0 +1,10 @@
+package io.trtc.tuikit.atomicx.callview.core.common.utils
+
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+
+object CallUtils {
+    fun isCaller(userId: String): Boolean {
+        val callerId = CallStore.shared.observerState.activeCall.value.inviterId
+        return callerId == userId
+    }
+}

+ 232 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/utils/ImageResourceCache.kt

@@ -0,0 +1,232 @@
+package io.trtc.tuikit.atomicx.callview.core.common.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+
+class ImageResourceCache(private val context: Context) {
+
+    private val drawablePathCache: MutableMap<Int, String> = mutableMapOf()
+    private var defaultAvatarPath: String? = null
+    
+    companion object {
+        private const val CACHE_DIR_ICONS = "icons"
+        private const val CACHE_DIR_AVATARS = "avatars"
+        private const val DEFAULT_AVATAR_FILE_NAME = "default_avatar.png"
+        private const val GIF_HEADER_SIZE = 6
+        private const val BUFFER_SIZE = 8192
+        private const val GIF_HEADER_PREFIX = "GIF8"
+    }
+
+    fun getDrawablePath(drawableResId: Int): String {
+        drawablePathCache[drawableResId]?.let {
+            return it
+        }
+        
+        return try {
+            val cacheDir = getIconsCacheDir()
+            val resourceName = context.resources.getResourceEntryName(drawableResId)
+            val isGif = isGifFormat(drawableResId, resourceName)
+            val fileExtension = if (isGif) "gif" else "png"
+            val iconFile = File(cacheDir, "${resourceName}.${fileExtension}")
+
+            if (iconFile.exists()) {
+                val absolutePath = iconFile.absolutePath
+                drawablePathCache[drawableResId] = absolutePath
+                return absolutePath
+            }
+
+            val absolutePath = if (isGif) {
+                copyGifResource(drawableResId, iconFile)
+            } else {
+                convertBitmapToFile(drawableResId, iconFile)
+            }
+            
+            if (absolutePath.isNotEmpty()) {
+                drawablePathCache[drawableResId] = absolutePath
+            }
+            absolutePath
+        } catch (e: Exception) {
+            Logger.e("getDrawablePath fail, e=${e.message}")
+            ""
+        }
+    }
+
+    fun getDefaultAvatarPath(defaultAvatarResId: Int): String? {
+        if (defaultAvatarPath != null) {
+            return defaultAvatarPath
+        }
+        
+        return try {
+            val cacheDir = getAvatarsCacheDir()
+            val defaultAvatarFile = File(cacheDir, DEFAULT_AVATAR_FILE_NAME)
+            
+            if (defaultAvatarFile.exists()) {
+                defaultAvatarPath = defaultAvatarFile.absolutePath
+                return defaultAvatarPath
+            }
+            
+            val bitmap = BitmapFactory.decodeResource(context.resources, defaultAvatarResId)
+                ?: return null
+            
+            val absolutePath = saveBitmapToFile(bitmap, defaultAvatarFile)
+            if (absolutePath != null) {
+                defaultAvatarPath = absolutePath
+            }
+            absolutePath
+        } catch (e: Exception) {
+            e.printStackTrace()
+            null
+        }
+    }
+
+    fun cacheNetworkImage(imageUrl: String?, callback: (String?) -> Unit) {
+        if (imageUrl.isNullOrEmpty()) {
+            callback(null)
+            return
+        }
+        
+        Glide.with(context)
+            .downloadOnly()
+            .load(imageUrl)
+            .listener(object : RequestListener<File> {
+                override fun onResourceReady(
+                    resource: File?,
+                    model: Any?,
+                    target: Target<File>?,
+                    dataSource: com.bumptech.glide.load.DataSource?,
+                    isFirstResource: Boolean
+                ): Boolean {
+                    callback(resource?.absolutePath)
+                    return false
+                }
+                
+                override fun onLoadFailed(
+                    e: GlideException?,
+                    model: Any?,
+                    target: Target<File>?,
+                    isFirstResource: Boolean
+                ): Boolean {
+                    callback(null)
+                    return false
+                }
+            })
+            .preload()
+    }
+    
+
+    private fun isGifFormat(drawableResId: Int, resourceName: String): Boolean {
+        return try {
+            context.resources.openRawResource(drawableResId).use { inputStream ->
+                val header = ByteArray(GIF_HEADER_SIZE)
+                val bytesRead = inputStream.read(header)
+                if (bytesRead >= GIF_HEADER_SIZE) {
+                    val headerString = String(header, Charsets.US_ASCII)
+                    headerString.startsWith(GIF_HEADER_PREFIX)
+                } else {
+                    false
+                }
+            }
+        } catch (e: Exception) {
+            resourceName.contains("loading", ignoreCase = true) ||
+            resourceName.contains("gif", ignoreCase = true)
+        }
+    }
+
+    private fun copyGifResource(drawableResId: Int, targetFile: File): String {
+        return try {
+            context.resources.openRawResource(drawableResId).use { inputStream ->
+                FileOutputStream(targetFile).use { outputStream ->
+                    val buffer = ByteArray(BUFFER_SIZE)
+                    var bytesRead: Int
+                    while (inputStream.read(buffer).also { bytesRead = it } != -1) {
+                        outputStream.write(buffer, 0, bytesRead)
+                    }
+                    outputStream.flush()
+                }
+            }
+            
+            if (!verifyGifFile(targetFile)) {
+                targetFile.delete()
+                return ""
+            }
+            
+            targetFile.absolutePath
+        } catch (e: IOException) {
+            Logger.e("copyGifResource fail, e=${e.message}")
+            targetFile.delete()
+            ""
+        }
+    }
+
+    private fun verifyGifFile(file: File): Boolean {
+        return try {
+            FileInputStream(file).use { inputStream ->
+                val header = ByteArray(GIF_HEADER_SIZE)
+                val bytesRead = inputStream.read(header)
+                if (bytesRead >= GIF_HEADER_SIZE) {
+                    val headerString = String(header, Charsets.US_ASCII)
+                    headerString.startsWith(GIF_HEADER_PREFIX)
+                } else {
+                    false
+                }
+            }
+        } catch (e: Exception) {
+            false
+        }
+    }
+
+    private fun convertBitmapToFile(drawableResId: Int, targetFile: File): String {
+        val bitmap = BitmapFactory.decodeResource(context.resources, drawableResId)
+            ?: return ""
+        
+        return saveBitmapToFile(bitmap, targetFile) ?: ""
+    }
+
+    private fun saveBitmapToFile(bitmap: Bitmap, targetFile: File): String? {
+        return try {
+            FileOutputStream(targetFile).use { outputStream ->
+                bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+                outputStream.flush()
+            }
+            targetFile.absolutePath
+        } catch (e: IOException) {
+            e.printStackTrace()
+            null
+        } finally {
+            bitmap.recycle()
+        }
+    }
+
+    private fun getIconsCacheDir(): File {
+        val cacheDir = context.cacheDir
+        val iconsCacheDir = File(cacheDir, CACHE_DIR_ICONS)
+        if (!iconsCacheDir.exists()) {
+            iconsCacheDir.mkdirs()
+        }
+        return iconsCacheDir
+    }
+
+    private fun getAvatarsCacheDir(): File {
+        val cacheDir = context.cacheDir
+        val avatarCacheDir = File(cacheDir, CACHE_DIR_AVATARS)
+        if (!avatarCacheDir.exists()) {
+            avatarCacheDir.mkdirs()
+        }
+        return avatarCacheDir
+    }
+    
+    fun clearCache() {
+        drawablePathCache.clear()
+        defaultAvatarPath = null
+    }
+}
+

+ 77 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/utils/Logger.kt

@@ -0,0 +1,77 @@
+package io.trtc.tuikit.atomicx.callview.core.common.utils
+
+import android.util.Log
+import com.tencent.liteav.base.ContextUtils
+import com.tencent.trtc.TRTCCloud
+import org.json.JSONException
+import org.json.JSONObject
+
+class Logger {
+    companion object {
+        private const val API = "TuikitLog"
+        private const val LOG_KEY_API = "api"
+        private const val LOG_KEY_PARAMS = "params"
+        private const val LOG_KEY_PARAMS_LEVEL = "level"
+        private const val LOG_KEY_PARAMS_MESSAGE = "message"
+        private const val LOG_KEY_PARAMS_FILE = "file"
+        private const val LOG_KEY_PARAMS_LINE = "line"
+        private const val LOG_KEY_PARAMS_MODULE = "module"
+        private const val LOG_VALUE_PARAMS_MODULE = "call_view"
+        private const val LOG_LEVEL_INFO = 0
+        private const val LOG_LEVEL_WARNING = 1
+        private const val LOG_LEVEL_ERROR = 2
+        private const val LOG_FUNCTION_CALLER_INDEX = 5
+
+        fun i(message: String) {
+            log(LOG_LEVEL_INFO, message)
+        }
+
+        fun w(message: String) {
+            log(LOG_LEVEL_WARNING, message)
+        }
+
+        fun e(message: String) {
+            log(LOG_LEVEL_ERROR, message)
+        }
+
+        private fun log(level: Int, message: String) {
+            var context = ContextUtils.getApplicationContext()
+            if (context == null) {
+                ContextUtils.initContextFromNative("liteav")
+                context = ContextUtils.getApplicationContext()
+            }
+            if (context == null) {
+                return
+            }
+            try {
+                val paramsJson = JSONObject()
+                paramsJson.put(LOG_KEY_PARAMS_LEVEL, level)
+                paramsJson.put(LOG_KEY_PARAMS_MESSAGE, message)
+                paramsJson.put(LOG_KEY_PARAMS_MODULE, LOG_VALUE_PARAMS_MODULE)
+                paramsJson.put(LOG_KEY_PARAMS_FILE, getCallerFileName())
+                paramsJson.put(LOG_KEY_PARAMS_LINE, getCallerLineNumber())
+
+                val loggerJson = JSONObject()
+                loggerJson.put(LOG_KEY_API, API)
+                loggerJson.put(LOG_KEY_PARAMS, paramsJson)
+
+                TRTCCloud.sharedInstance(context)
+                    .callExperimentalAPI(loggerJson.toString())
+            } catch (e: JSONException) {
+                Log.e("Logger", e.toString())
+            }
+        }
+
+        private fun getCallerFileName(): String {
+            val stackTrace = Thread.currentThread().stackTrace
+            if (stackTrace.size < LOG_FUNCTION_CALLER_INDEX + 1) return ""
+            return stackTrace[LOG_FUNCTION_CALLER_INDEX].fileName ?: ""
+        }
+
+        private fun getCallerLineNumber(): Int {
+            val stackTrace = Thread.currentThread().stackTrace
+            if (stackTrace.size < LOG_FUNCTION_CALLER_INDEX + 1) return 0
+            return stackTrace[LOG_FUNCTION_CALLER_INDEX].lineNumber
+        }
+    }
+}

+ 52 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/utils/PermissionRequest.kt

@@ -0,0 +1,52 @@
+package io.trtc.tuikit.atomicx.callview.core.common.utils
+
+import android.Manifest
+import android.content.Context
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import com.tencent.qcloud.tuicore.permission.PermissionRequester
+import io.trtc.tuikit.atomicx.R
+
+object PermissionRequest {
+    fun requestCameraPermission(context: Context, callback: PermissionCallback?) {
+        if (PermissionRequester.newInstance(Manifest.permission.CAMERA).has()) {
+            callback?.onGranted()
+            return
+        }
+
+        val permissionCallback: PermissionCallback = object : PermissionCallback() {
+            override fun onGranted() {
+                callback?.onGranted()
+            }
+
+            override fun onDenied() {
+                super.onDenied()
+                callback?.onDenied()
+            }
+        }
+
+        val title = context.getString(R.string.callview_permission_camera)
+        val reason = getCameraPermissionHint(context)
+        val appName = context.packageManager.getApplicationLabel(context.applicationInfo).toString()
+
+        PermissionRequester.newInstance(Manifest.permission.CAMERA)
+            .title(context.getString(R.string.callview_permission_title, appName, title))
+            .description(reason)
+            .settingsTip("${context.getString(R.string.callview_permission_tips, title)} $reason".trimIndent())
+            .callback(permissionCallback)
+            .request()
+    }
+
+    private fun getCameraPermissionHint(context: Context): String {
+        val cameraPermissionsDescription = TUICore.createObject(
+            TUIConstants.Privacy.PermissionsFactory.FACTORY_NAME,
+            TUIConstants.Privacy.PermissionsFactory.PermissionsName.CAMERA_PERMISSIONS, null
+        ) as String?
+        return if (!cameraPermissionsDescription.isNullOrEmpty()) {
+            cameraPermissionsDescription
+        } else {
+            context.getString(R.string.callview_permission_camera_reason)
+        }
+    }
+}

+ 33 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/core/common/widget/ControlButton.kt

@@ -0,0 +1,33 @@
+package io.trtc.tuikit.atomicx.callview.core.common.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.utils.widget.ImageFilterView
+import io.trtc.tuikit.atomicx.R
+
+class ControlButton @JvmOverloads constructor(
+    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : LinearLayout(context, attrs, defStyleAttr) {
+    val imageView: ImageFilterView
+    val textView: TextView
+
+    init {
+        LayoutInflater.from(context).inflate(R.layout.callview_function_view_video_item, this, true)
+        imageView = findViewById(R.id.iv_function)
+        textView = findViewById(R.id.tv_function)
+
+        attrs?.let {
+            val typedArray = context.obtainStyledAttributes(it, R.styleable.ControlButton, 0, 0)
+            val iconRes = typedArray.getResourceId(R.styleable.ControlButton_cbIcon, 0)
+            val text = typedArray.getString(R.styleable.ControlButton_cbText)
+            if (iconRes != 0) {
+                imageView.setImageResource(iconRes)
+            }
+            textView.text = text ?: ""
+            typedArray.recycle()
+        }
+    }
+}

+ 189 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/aisubtitle/AISubtitle.kt

@@ -0,0 +1,189 @@
+package io.trtc.tuikit.atomicx.callview.public.aisubtitle
+
+import android.content.Context
+import android.graphics.Typeface
+import android.os.Handler
+import android.os.Looper
+import android.text.SpannableString
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.style.ForegroundColorSpan
+import android.text.style.StyleSpan
+import android.util.AttributeSet
+import android.widget.ScrollView
+import androidx.appcompat.widget.AppCompatTextView
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.trtc.TRTCCloudListener
+import io.trtc.tuikit.atomicx.callview.core.common.Constants.AI_TRANSLATION_ROBOT
+import io.trtc.tuikit.atomicx.callview.core.common.utils.Logger
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import org.json.JSONObject
+import java.util.Timer
+import java.util.TimerTask
+
+class AISubtitle(context: Context, attrs: AttributeSet?) : ScrollView(context, attrs) {
+    private var hideTimer: Timer? = null
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private val translationInfo = mutableListOf<TranslationInfo>()
+    private lateinit var textView: AppCompatTextView
+
+    init {
+        initView()
+    }
+
+    private fun initView() {
+        isVerticalScrollBarEnabled = true
+        isScrollbarFadingEnabled = false
+        textView = AppCompatTextView(context).apply {
+            layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
+            setPadding(16, 16, 16, 16)
+            textSize = 14f
+            setTextColor(0xFFFFFFFF.toInt())
+        }
+        addView(textView)
+    }
+
+    private val trtcCloudListener = object : TRTCCloudListener() {
+        override fun onRecvCustomCmdMsg(userId: String?, cmdID: Int, seq: Int, message: ByteArray?) {
+            super.onRecvCustomCmdMsg(userId, cmdID, seq, message)
+            if (userId == null || message == null) return
+
+            try {
+                val messageString = String(message)
+                val jsonObject = JSONObject(messageString)
+                val type = jsonObject.optInt("type")
+
+                if (type == AI_MESSAGE_TYPE) {
+                    val sender = jsonObject.optString("sender")
+                    val payload = jsonObject.optJSONObject("payload")
+                    val text = payload?.optString("text") ?: ""
+                    val translationText = payload?.optString("translation_text") ?: ""
+                    val roundId = payload?.optString("roundid") ?: ""
+                    val translationLanguage = payload?.optString("translation_language") ?: ""
+                    if (roundId.isNotEmpty()) {
+                        val index = translationInfo.indexOfFirst { it.roundId == roundId }
+                        if (index != -1) {
+                            translationInfo[index].sender =
+                                if (sender.contains(AI_TRANSLATION_ROBOT)) translationInfo[index].sender else sender
+                            translationInfo[index].text = text.ifEmpty {
+                                translationInfo[index].text
+                            }
+                            translationInfo[index].translation[translationLanguage] =
+                                (translationText.ifEmpty { translationInfo[index].translation[translationLanguage] }) as String
+                        } else {
+                            val translationInfoItem = TranslationInfo()
+                            translationInfoItem.roundId = roundId
+                            translationInfoItem.sender =
+                                if (sender.contains(AI_TRANSLATION_ROBOT)) "" else sender
+                            translationInfoItem.text = text
+                            if (translationLanguage.isNotEmpty()) {
+                                translationInfoItem.translation[translationLanguage] = translationText
+                            }
+                            translationInfo.add(translationInfoItem)
+                        }
+                        updateView()
+                    }
+                }
+            } catch (e: Exception) {
+                Logger.e("translation fail, e=${e.message}")
+            }
+        }
+    }
+
+    private fun updateView() {
+        val spannableBuilder = SpannableStringBuilder()
+        for ((index, message) in translationInfo.withIndex()) {
+            val translationText = sortLanguageType(message)
+            val displayName = getUserDisplayName(message.sender)
+            val fullText = "$displayName:\n${message.text}\n$translationText"
+            formatAISubtitleText(spannableBuilder, displayName, fullText)
+            if (index < translationInfo.size - 1) {
+                spannableBuilder.append("\n")
+            }
+        }
+        hideAISubtitleAfterDelay(spannableBuilder)
+    }
+
+    private fun sortLanguageType(message: TranslationInfo): StringBuilder {
+        val translationText = StringBuilder()
+        val languageOrder =
+            listOf("zh", "en", "es", "pt", "fr", "de", "ru", "ar", "ja", "ko", "vi", "ms", "id", "it", "th")
+        for (language in languageOrder) {
+            message.translation[language]?.let { translation ->
+                translationText.append("[$language]: $translation\n")
+            }
+        }
+        return translationText
+    }
+
+    private fun formatAISubtitleText(
+        spannableBuilder: SpannableStringBuilder,
+        displayName: String,
+        fullText: String
+    ) {
+        val spannableString = SpannableString(fullText)
+        val nameStart = 0
+        val nameEnd = displayName.length + 1
+        spannableString.setSpan(ForegroundColorSpan(0xFFD9CC66.toInt()),
+            nameStart,
+            nameEnd,
+            Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+        )
+        spannableString.setSpan(
+            StyleSpan(Typeface.BOLD),
+            nameStart,
+            nameEnd,
+            Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+        )
+        spannableBuilder.append(spannableString)
+    }
+
+    private fun hideAISubtitleAfterDelay(textBuilder: SpannableStringBuilder) {
+        mainHandler.post {
+            textView.text = textBuilder
+            visibility = VISIBLE
+            post {
+                fullScroll(ScrollView.FOCUS_DOWN)
+            }
+            hideTimer?.cancel()
+            hideTimer = Timer().apply {
+                schedule(object : TimerTask() {
+                    override fun run() {
+                        mainHandler.post {
+                            visibility = GONE
+                        }
+                    }
+                }, SHOW_DURATION * 1000)
+            }
+        }
+    }
+
+    private fun getUserDisplayName(userId: String): String {
+        val participant = CallStore.shared.observerState.allParticipants.value.find { it.id == userId }
+        return participant?.name?.takeIf { it.isNotEmpty() } ?: userId
+    }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        TUICallEngine.createInstance(context).trtcCloudInstance.addListener(trtcCloudListener)
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        TUICallEngine.createInstance(context).trtcCloudInstance.removeListener(trtcCloudListener)
+        hideTimer?.cancel()
+    }
+
+    private class TranslationInfo {
+        var roundId: String = ""
+        var sender: String = ""
+        var text: String = ""
+        var translation: MutableMap<String, String> = mutableMapOf()
+    }
+
+    companion object {
+        private const val TAG = "AISubtitle"
+        private const val AI_MESSAGE_TYPE = 10000
+        private const val SHOW_DURATION: Long = 8
+    }
+}

+ 43 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/AudioAndVideoCalleeWaitingView.kt

@@ -0,0 +1,43 @@
+package io.trtc.tuikit.atomicx.callview.public.controls
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import android.widget.RelativeLayout
+import androidx.constraintlayout.utils.widget.ImageFilterView
+import androidx.core.content.ContextCompat
+import com.trtc.tuikit.common.imageloader.ImageLoader
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+
+class AudioAndVideoCalleeWaitingView(context: Context) : RelativeLayout(context) {
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        this.layoutParams?.width = LayoutParams.MATCH_PARENT
+        this.layoutParams?.height = LayoutParams.MATCH_PARENT
+        initView()
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.callview_function_view_invited_waiting, this)
+        val layoutReject: LinearLayout = findViewById(R.id.ll_reject)
+        val layoutDialing: LinearLayout = findViewById(R.id.ll_answer)
+        val imageViewReject: ImageFilterView = findViewById(R.id.img_reject)
+        layoutDialing.isEnabled = true
+
+        layoutReject.setOnClickListener {
+            imageViewReject.roundPercent = 1.0f
+            imageViewReject.setBackgroundColor(ContextCompat.getColor(context, R.color.callview_button_bg_red))
+            ImageLoader.loadGif(context, imageViewReject, R.drawable.callview_hangup_loading)
+            layoutDialing.isEnabled = false
+            layoutDialing.alpha = 0.8f
+            CallStore.shared.reject(null)
+        }
+        layoutDialing.setOnClickListener {
+            if (!layoutDialing.isEnabled) {
+                return@setOnClickListener
+            }
+            CallStore.shared.accept(null)
+        }
+    }
+}

+ 124 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/AudioCallerWaitingAndAcceptedView.kt

@@ -0,0 +1,124 @@
+package io.trtc.tuikit.atomicx.callview.public.controls
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.RelativeLayout
+import androidx.core.content.ContextCompat
+import com.trtc.tuikit.common.imageloader.ImageLoader
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.callview.core.common.widget.ControlButton
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.device.AudioRoute
+import io.trtc.tuikit.atomicxcore.api.device.DeviceStatus
+import io.trtc.tuikit.atomicxcore.api.device.DeviceStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+class AudioCallerWaitingAndAcceptedView(context: Context) : RelativeLayout(context) {
+    private var subscribeStateJob: Job? = null
+
+    private lateinit var buttonMicrophone: ControlButton
+    private lateinit var buttonAudioDevice: ControlButton
+    private lateinit var buttonHangup: ControlButton
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        this.layoutParams?.width = LayoutParams.MATCH_PARENT
+        this.layoutParams?.height = LayoutParams.MATCH_PARENT
+        initView()
+        registerObserver()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        subscribeStateJob?.cancel()
+    }
+
+    private fun registerObserver() {
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            launch { observeDeviceStatus() }
+            launch { observeAudioRoute() }
+        }
+    }
+
+    private suspend fun observeDeviceStatus() {
+        DeviceStore.shared().deviceState.microphoneStatus.collect {
+            updateMicrophoneButtonView()
+        }
+    }
+
+    private suspend fun observeAudioRoute() {
+        DeviceStore.shared().deviceState.currentAudioRoute.collect { audioRoute ->
+            updateAudioRouteButton(audioRoute)
+        }
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.callview_function_view_audio, this)
+        buttonMicrophone = findViewById(R.id.cb_mic)
+        buttonHangup = findViewById(R.id.cb_hangup)
+        buttonAudioDevice = findViewById(R.id.cb_audio_device)
+        buttonMicrophone.visibility = VISIBLE
+        buttonAudioDevice.visibility = VISIBLE
+        updateMicrophoneButtonView()
+
+        val currentAudioRoute = DeviceStore.shared().deviceState.currentAudioRoute.value
+        updateAudioRouteButton(currentAudioRoute)
+        initViewListener()
+    }
+
+    private fun updateAudioRouteButton(audioRoute: AudioRoute) {
+        val isSpeaker = audioRoute == AudioRoute.SPEAKERPHONE
+        val resId = if (isSpeaker) R.string.callview_text_speaker else R.string.callview_text_use_earpiece
+        buttonAudioDevice.textView.text = context.getString(resId)
+        buttonAudioDevice.imageView.isActivated = isSpeaker
+        buttonAudioDevice.imageView.setImageResource(R.drawable.callview_bg_audio_device)
+    }
+
+    private fun initViewListener() {
+        buttonMicrophone.setOnClickListener {
+            if (!buttonMicrophone.isEnabled) {
+                return@setOnClickListener
+            }
+            val currentMicrophoneStatus = DeviceStore.shared().deviceState.microphoneStatus.value
+            if (currentMicrophoneStatus == DeviceStatus.ON) {
+                DeviceStore.shared().closeLocalMicrophone()
+            } else {
+                DeviceStore.shared().openLocalMicrophone(null)
+            }
+        }
+        buttonHangup.setOnClickListener {
+            buttonHangup.imageView.roundPercent = 1.0f
+            buttonHangup.imageView.setBackgroundColor(ContextCompat.getColor(context, R.color.callview_button_bg_red))
+            ImageLoader.loadGif(context, buttonHangup.imageView, R.drawable.callview_hangup_loading)
+            disableButton(buttonMicrophone)
+            disableButton(buttonAudioDevice)
+            CallStore.shared.hangup(null)
+        }
+        buttonAudioDevice.setOnClickListener {
+            val currentAudioRoute = DeviceStore.shared().deviceState.currentAudioRoute.value
+            if (currentAudioRoute == AudioRoute.SPEAKERPHONE) {
+                DeviceStore.shared().setAudioRoute(AudioRoute.EARPIECE)
+            } else {
+                DeviceStore.shared().setAudioRoute(AudioRoute.SPEAKERPHONE)
+            }
+        }
+    }
+
+    private fun disableButton(button: ControlButton) {
+        button.isEnabled = false
+        button.alpha = 0.8f
+    }
+
+    private fun updateMicrophoneButtonView() {
+        val microphoneStatus = DeviceStore.shared().deviceState.microphoneStatus.value
+        buttonMicrophone.imageView.isActivated = (microphoneStatus == DeviceStatus.OFF)
+        buttonMicrophone.textView.text = if (microphoneStatus == DeviceStatus.OFF) {
+            context.getString(R.string.callview_toast_enable_mute)
+        } else {
+            context.getString(R.string.callview_toast_disable_mute)
+        }
+    }
+}

+ 76 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/MultiCallControlsView.kt

@@ -0,0 +1,76 @@
+package io.trtc.tuikit.atomicx.callview.public.controls
+
+import android.content.Context
+import android.widget.RelativeLayout
+import androidx.constraintlayout.widget.ConstraintLayout
+import io.trtc.tuikit.atomicx.callview.core.common.utils.CallUtils
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.call.CallParticipantStatus
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+
+class MultiCallControlsView(context: Context) : ConstraintLayout(context) {
+    private var subscribeStateJob: Job? = null
+    private var functionLayout: RelativeLayout? = null
+    private var callStatus = CallParticipantStatus.None
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        updateLayout()
+        registerObserver()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        subscribeStateJob?.cancel()
+    }
+
+    private fun registerObserver() {
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            supervisorScope {
+                launch { observeSelfInfo() }
+                launch { observeAllParticipants() }
+            }
+        }
+    }
+
+    private suspend fun observeSelfInfo() {
+        CallStore.shared.observerState.selfInfo.collect { selfInfo ->
+            if (callStatus != selfInfo.status && selfInfo.status == CallParticipantStatus.Accept) {
+                callStatus = selfInfo.status
+                updateLayout()
+            }
+        }
+    }
+
+    private suspend fun observeAllParticipants() {
+        CallStore.shared.observerState.allParticipants.collect { participants ->
+            if (participants.size > 2) {
+                if (functionLayout is VideoCallerAndCalleeAcceptedView) {
+                    return@collect
+                }
+                updateLayout()
+            }
+        }
+    }
+
+    private fun updateLayout() {
+        val selfInfo = CallStore.shared.observerState.selfInfo.value
+        val newLayout = if (selfInfo.status == CallParticipantStatus.Waiting && !CallUtils.isCaller(selfInfo.id)) {
+                AudioAndVideoCalleeWaitingView(context)
+            } else {
+                VideoCallerAndCalleeAcceptedView(context)
+            }
+
+        functionLayout?.takeIf { it.javaClass == newLayout.javaClass }?.let {
+            return
+        }
+
+        functionLayout = newLayout
+        removeAllViews()
+        addView(functionLayout)
+    }
+}

+ 79 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/SingleCallControlsView.kt

@@ -0,0 +1,79 @@
+package io.trtc.tuikit.atomicx.callview.public.controls
+
+import android.content.Context
+import android.widget.RelativeLayout
+import androidx.constraintlayout.widget.ConstraintLayout
+import io.trtc.tuikit.atomicx.callview.core.common.utils.Logger
+import io.trtc.tuikit.atomicxcore.api.call.CallMediaType
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.call.CallParticipantStatus
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+class SingleCallControlsView(context: Context) : ConstraintLayout(context) {
+    private var subscribeStateJob: Job? = null
+    private var functionLayout: RelativeLayout? = null
+    private var callStatus = CallParticipantStatus.None
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        updateLayout()
+        registerSelfObserver()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        subscribeStateJob?.cancel()
+    }
+
+    private fun registerSelfObserver() {
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            CallStore.shared.observerState.selfInfo.collect { selfInfo ->
+                if (callStatus != selfInfo.status && selfInfo.status == CallParticipantStatus.Accept) {
+                    callStatus = selfInfo.status
+                    updateLayout()
+                }
+            }
+        }
+    }
+
+    private fun updateLayout() {
+        val mediaType = CallStore.shared.observerState.activeCall.value.mediaType
+        val selfStatus = CallStore.shared.observerState.selfInfo.value.status
+
+        when {
+            selfStatus == CallParticipantStatus.Waiting && !selfIsCaller() -> {
+                functionLayout = AudioAndVideoCalleeWaitingView(context)
+            }
+
+            selfStatus == CallParticipantStatus.Waiting && selfIsCaller() -> {
+                functionLayout = when (mediaType) {
+                    CallMediaType.Video -> VideoCallerWaitingView(context)
+                    else -> AudioCallerWaitingAndAcceptedView(context)
+                }
+            }
+
+            selfStatus == CallParticipantStatus.Accept -> {
+                functionLayout = when (mediaType) {
+                    CallMediaType.Video -> VideoCallerAndCalleeAcceptedView(context)
+                    else -> AudioCallerWaitingAndAcceptedView(context)
+                }
+            }
+        }
+
+        if (functionLayout == null) {
+            Logger.e("functionLayout == null")
+        } else {
+            removeAllViews()
+            addView(functionLayout)
+        }
+    }
+
+    private fun selfIsCaller(): Boolean {
+        val selfId = CallStore.shared.observerState.selfInfo.value.id
+        val callerId = CallStore.shared.observerState.activeCall.value.inviterId
+        return selfId == callerId
+    }
+}

+ 378 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/VideoCallerAndCalleeAcceptedView.kt

@@ -0,0 +1,378 @@
+package io.trtc.tuikit.atomicx.callview.public.controls
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageView
+import android.widget.RelativeLayout
+import androidx.constraintlayout.utils.widget.ImageFilterView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.core.content.ContextCompat
+import androidx.transition.ChangeBounds
+import androidx.transition.TransitionManager
+import com.trtc.tuikit.common.imageloader.ImageLoader
+import android.graphics.drawable.Drawable
+import android.view.MotionEvent
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import io.trtc.tuikit.atomicx.callview.core.common.Constants
+import com.trtc.tuikit.common.util.ScreenUtil
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.callview.core.common.Constants.BLUR_LEVEL_CLOSE
+import io.trtc.tuikit.atomicx.callview.core.common.Constants.BLUR_LEVEL_HIGH
+import io.trtc.tuikit.atomicx.callview.core.common.utils.Logger
+import io.trtc.tuikit.atomicx.callview.core.common.utils.PermissionRequest
+import io.trtc.tuikit.atomicx.callview.core.common.widget.ControlButton
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.call.CallParticipantStatus
+import io.trtc.tuikit.atomicxcore.api.device.AudioRoute
+import io.trtc.tuikit.atomicxcore.api.device.DeviceStatus
+import io.trtc.tuikit.atomicxcore.api.device.DeviceStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+import java.util.concurrent.CopyOnWriteArraySet
+
+class VideoCallerAndCalleeAcceptedView(context: Context) : RelativeLayout(context) {
+    private var subscribeStateJob: Job? = null
+
+    private lateinit var rootView: ConstraintLayout
+    private lateinit var imageHangup: ImageFilterView
+    private lateinit var imageSwitchCamera: ImageView
+    private lateinit var imageExpandView: ImageView
+    private lateinit var imageBlurBackground: ImageView
+
+    private lateinit var buttonMicrophone: ControlButton
+    private lateinit var buttonAudioDevice: ControlButton
+    private lateinit var buttonCamera: ControlButton
+
+    private var defaultAudioButtonDrawable: Drawable? = null
+
+    private var isBottomViewExpand: Boolean = false
+    private var enableTransition: Boolean = false
+    private val originalSet = ConstraintSet()
+    private val rowSet = ConstraintSet()
+
+    private var hasTriggeredSlideAnimation: Boolean = false
+    private var isEnableBlurBackground:Boolean = false
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        this.layoutParams?.width = LayoutParams.MATCH_PARENT
+        this.layoutParams?.height = LayoutParams.MATCH_PARENT
+        enableTransition = true
+        initView()
+        registerObserver()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        subscribeStateJob?.cancel()
+    }
+
+    private fun registerObserver() {
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            supervisorScope {
+                launch { observeCameraStatus() }
+                launch { observeMicrophoneStatus() }
+                launch { observeAudioRoute() }
+            }
+        }
+    }
+
+    private suspend fun observeCameraStatus() {
+        DeviceStore.shared().deviceState.cameraStatus.collect { cameraStatus ->
+            val cameraIsOpened = (cameraStatus == DeviceStatus.ON)
+            buttonCamera.imageView.isActivated = cameraIsOpened
+            buttonCamera.textView.text = when {
+                cameraIsOpened -> context.getString(R.string.callview_toast_enable_camera)
+                else -> context.getString(R.string.callview_toast_disable_camera)
+            }
+            updateSwitchCameraAndBlurBackgroundButton(cameraIsOpened)
+        }
+    }
+
+    private suspend fun observeMicrophoneStatus() {
+        DeviceStore.shared().deviceState.microphoneStatus.collect { microphoneStatus ->
+            val isMute = microphoneStatus == DeviceStatus.OFF
+            val resId = if (isMute) {
+                R.string.callview_toast_enable_mute
+            } else {
+                R.string.callview_toast_disable_mute
+            }
+            buttonMicrophone.textView.text = context.getString(resId)
+            buttonMicrophone.imageView.isActivated = isMute
+        }
+    }
+
+    private suspend fun observeAudioRoute() {
+        DeviceStore.shared().deviceState.currentAudioRoute.collect { audioRoute ->
+            updateAudioRouteButton(audioRoute)
+        }
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.callview_function_view_video, this)
+        rootView = findViewById(R.id.cl_view_video)
+        buttonMicrophone = findViewById(R.id.cb_microphone)
+        buttonAudioDevice = findViewById(R.id.cb_speaker)
+        buttonCamera = findViewById(R.id.cb_open_camera)
+
+        imageHangup = findViewById(R.id.iv_hang_up)
+        imageSwitchCamera = findViewById(R.id.iv_function_switch_camera)
+        imageBlurBackground = findViewById(R.id.img_blur_background)
+        imageExpandView = findViewById(R.id.iv_expanded)
+
+        val isMute = DeviceStore.shared().deviceState.microphoneStatus.value == DeviceStatus.OFF
+        buttonMicrophone.imageView.isActivated = isMute
+        val micResId = if (isMute) R.string.callview_toast_disable_mute else R.string.callview_toast_enable_mute
+        buttonMicrophone.textView.text = context.getString(micResId)
+        val currentRoute = DeviceStore.shared().deviceState.currentAudioRoute.value
+        updateAudioRouteButton(currentRoute)
+        defaultAudioButtonDrawable = buttonAudioDevice.imageView.drawable
+        buttonMicrophone.visibility = VISIBLE
+        buttonAudioDevice.visibility = VISIBLE
+        buttonCamera.visibility = VISIBLE
+        imageExpandView.visibility = if (isMultiCall()) View.VISIBLE else View.GONE
+
+        val isCameraOpened = (DeviceStore.shared().deviceState.cameraStatus.value == DeviceStatus.ON)
+        updateSwitchCameraAndBlurBackgroundButton(isCameraOpened)
+        originalSet.clone(rootView)
+        rowSet.clone(rootView)
+        buildRowConstraint(rowSet)
+        startAnimation(true)
+
+        initViewListener()
+    }
+
+    private fun updateAudioRouteButton(audioRoute: AudioRoute) {
+        val isSpeaker = audioRoute == AudioRoute.SPEAKERPHONE
+        val resId = if (isSpeaker) R.string.callview_text_speaker else R.string.callview_text_use_earpiece
+        buttonAudioDevice.textView.text = context.getString(resId)
+        buttonAudioDevice.imageView.isActivated = isSpeaker
+        buttonAudioDevice.imageView.setImageResource(R.drawable.callview_bg_audio_device)
+    }
+
+    private fun initViewListener() {
+        buttonMicrophone.setOnClickListener {
+            if (!buttonMicrophone.isEnabled) {
+                return@setOnClickListener
+            }
+            val isMicrophoneOpen = (DeviceStore.shared().deviceState.microphoneStatus.value == DeviceStatus.ON)
+            if (isMicrophoneOpen) {
+                DeviceStore.shared().closeLocalMicrophone()
+            } else {
+                DeviceStore.shared().openLocalMicrophone(null)
+            }
+        }
+        buttonAudioDevice.setOnClickListener {
+            val currentAudioRoute = DeviceStore.shared().deviceState.currentAudioRoute.value
+            if (currentAudioRoute == AudioRoute.SPEAKERPHONE) {
+                DeviceStore.shared().setAudioRoute(AudioRoute.EARPIECE)
+            } else {
+                DeviceStore.shared().setAudioRoute(AudioRoute.SPEAKERPHONE)
+            }
+        }
+        buttonCamera.setOnClickListener {
+            if (!buttonCamera.isEnabled) {
+                return@setOnClickListener
+            }
+            if (DeviceStore.shared().deviceState.cameraStatus.value == DeviceStatus.ON) {
+                DeviceStore.shared().closeLocalCamera()
+            } else {
+                openLocalCamera()
+            }
+        }
+
+        imageHangup.setOnClickListener {
+            imageHangup.roundPercent = 1.0f
+            imageHangup.setBackgroundColor(ContextCompat.getColor(context, R.color.callview_button_bg_red))
+            ImageLoader.loadGif(context, imageHangup, R.drawable.callview_hangup_loading)
+
+            disableButton(buttonMicrophone)
+            disableButton(buttonAudioDevice)
+            disableButton(buttonCamera)
+            disableButton(imageSwitchCamera)
+            disableButton(imageBlurBackground)
+
+            CallStore.shared.hangup(null)
+        }
+
+        imageExpandView.setOnClickListener() {
+            startAnimation(!isBottomViewExpand)
+        }
+
+        imageBlurBackground.setOnClickListener {
+            if (!imageBlurBackground.isEnabled) {
+                return@setOnClickListener
+            }
+            enableBlurBackground(!isEnableBlurBackground)
+        }
+
+        imageSwitchCamera.setOnClickListener() {
+            if (!imageSwitchCamera.isEnabled) {
+                return@setOnClickListener
+            }
+            val isFront = DeviceStore.shared().deviceState.isFrontCamera.value
+            DeviceStore.shared().switchCamera(!isFront)
+        }
+    }
+
+    private fun disableButton(button: View) {
+        button.isEnabled = false
+        button.alpha = 0.8f
+    }
+
+    private fun startAnimation(isExpand: Boolean) {
+        if (!isMultiCall()) {
+            return
+        }
+        if (!enableTransition) {
+            return
+        }
+        if (isExpand == isBottomViewExpand) {
+            return
+        }
+        rootView.background = ContextCompat.getDrawable(context, R.drawable.callview_bg_group_call_bottom)
+        isBottomViewExpand = isExpand
+
+        val transition = ChangeBounds().apply { duration = 300 }
+        TransitionManager.beginDelayedTransition(rootView, transition)
+        updateButtonSize(isExpand)
+        if (isExpand) {
+            originalSet.applyTo(rootView)
+        } else {
+            rowSet.applyTo(rootView)
+            imageExpandView.rotation = 180f
+        }
+        val isCameraOpen = DeviceStore.shared().deviceState.cameraStatus.value == DeviceStatus.ON
+        updateSwitchCameraAndBlurBackgroundButton(isCameraOpen)
+        imageExpandView.visibility = if (enableTransition) View.VISIBLE else View.GONE
+        setControlButtonTextVisible(isExpand)
+    }
+
+    private fun setControlButtonTextVisible(visible: Boolean) {
+        buttonMicrophone.textView.visibility = if (visible) View.VISIBLE else View.GONE
+        buttonAudioDevice.textView.visibility = if (visible) View.VISIBLE else View.GONE
+        buttonCamera.textView.visibility = if (visible) View.VISIBLE else View.GONE
+    }
+
+    private fun updateButtonSize(isExpand: Boolean) {
+        val size = if (isExpand) ScreenUtil.dip2px(60f) else ScreenUtil.dip2px(48f)
+        buttonMicrophone.imageView.layoutParams?.let { it.width = size; it.height = size }
+        buttonAudioDevice.imageView.layoutParams?.let { it.width = size; it.height = size }
+        buttonCamera.imageView.layoutParams?.let { it.width = size; it.height = size }
+    }
+
+    private fun buildRowConstraint(set: ConstraintSet) {
+        val disableButtonSet: MutableSet<Constants.ControlButton> = CopyOnWriteArraySet()
+
+        val buttonIds = mutableListOf(imageExpandView.id).apply {
+            if (!disableButtonSet.contains(Constants.ControlButton.Microphone)) {
+                add(buttonMicrophone.id)
+            }
+            if (!disableButtonSet.contains(Constants.ControlButton.AudioPlaybackDevice)) {
+                add(buttonAudioDevice.id)
+            }
+            if (!disableButtonSet.contains(Constants.ControlButton.Camera)) {
+                add(buttonCamera.id)
+            }
+            add(imageHangup.id)
+        }
+
+        buttonIds.forEach {
+            set.clear(it)
+            set.setVisibility(it, View.VISIBLE)
+            val size = if (it == imageHangup.id) ScreenUtil.dip2px(48f) else ConstraintSet.WRAP_CONTENT
+            set.constrainWidth(it, size)
+            set.constrainHeight(it, size)
+        }
+        set.createHorizontalChainRtl(
+            ConstraintSet.PARENT_ID, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.END,
+            buttonIds.toIntArray(), null, ConstraintSet.CHAIN_SPREAD
+        )
+        val margin = ScreenUtil.dip2px(20f)
+        buttonIds.forEach {
+            set.connect(it, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, margin)
+            set.connect(it, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM, margin)
+        }
+        set.setMargin(imageExpandView.id, ConstraintSet.START, margin)
+        set.setMargin(imageHangup.id, ConstraintSet.END, margin)
+    }
+
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        when (event.action) {
+            MotionEvent.ACTION_DOWN -> {
+                touchStartY = event.y
+                hasTriggeredSlideAnimation = false
+            }
+
+            MotionEvent.ACTION_MOVE -> {
+                val selfUser = CallStore.shared.observerState.selfInfo.value.copy()
+                if (selfUser.status != CallParticipantStatus.Accept) {
+                    return true
+                }
+                if (!enableTransition || hasTriggeredSlideAnimation) {
+                    return true
+                }
+                val deltaY = event.y - touchStartY
+                val threshold = 50
+                if (deltaY < -threshold && !isBottomViewExpand) {
+                    startAnimation(true)
+                    hasTriggeredSlideAnimation = true
+                } else if (deltaY > threshold && isBottomViewExpand) {
+                    startAnimation(false)
+                    hasTriggeredSlideAnimation = true
+                }
+            }
+        }
+        return true
+    }
+
+    private var touchStartY: Float = 0f
+
+    private fun enableBlurBackground(enable: Boolean) {
+        Logger.i("setBlurBackground, enable: $enable")
+        val level = if (enable) BLUR_LEVEL_HIGH else BLUR_LEVEL_CLOSE
+        isEnableBlurBackground = enable
+        TUICallEngine.createInstance(context).setBlurBackground(level, object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+                Logger.i("setBlurBackground success.")
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                isEnableBlurBackground = false
+                Logger.e("setBlurBackground failed, errCode: $errCode, errMsg: $errMsg")
+            }
+        })
+    }
+
+    private fun openLocalCamera() {
+        PermissionRequest.requestCameraPermission(context, object : PermissionCallback() {
+            override fun onGranted() {
+                val isFrontCamera = DeviceStore.shared().deviceState.isFrontCamera.value
+                DeviceStore.shared().openLocalCamera(isFrontCamera, null)
+            }
+
+            override fun onDenied() {
+                Logger.e("openCamera failed, errMsg: camera permission denied")
+            }
+        })
+    }
+
+    private fun isMultiCall(): Boolean {
+        val inviteeIdListSize = CallStore.shared.observerState.activeCall.value.inviteeIds.size
+        val chatGroupId = CallStore.shared.observerState.activeCall.value.chatGroupId
+        return chatGroupId.isNotEmpty() || inviteeIdListSize > 1
+    }
+
+    private fun updateSwitchCameraAndBlurBackgroundButton(cameraIsOpened: Boolean) {
+        val visibility = if (cameraIsOpened && (!isMultiCall() || isBottomViewExpand)) VISIBLE else GONE
+        imageSwitchCamera.visibility = visibility
+        imageBlurBackground.visibility = visibility
+    }
+}

+ 150 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/controls/VideoCallerWaitingView.kt

@@ -0,0 +1,150 @@
+package io.trtc.tuikit.atomicx.callview.public.controls
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.RelativeLayout
+import androidx.core.content.ContextCompat
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import com.trtc.tuikit.common.imageloader.ImageLoader
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.callview.core.common.Constants.BLUR_LEVEL_CLOSE
+import io.trtc.tuikit.atomicx.callview.core.common.Constants.BLUR_LEVEL_HIGH
+import io.trtc.tuikit.atomicx.callview.core.common.utils.Logger
+import io.trtc.tuikit.atomicx.callview.core.common.utils.PermissionRequest
+import io.trtc.tuikit.atomicx.callview.core.common.widget.ControlButton
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.device.DeviceStatus
+import io.trtc.tuikit.atomicxcore.api.device.DeviceStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+class VideoCallerWaitingView(context: Context) : RelativeLayout(context) {
+    private var subscribeStateJob: Job? = null
+
+    private lateinit var buttonCancel: ControlButton
+    private lateinit var buttonSwitchCamera: ControlButton
+    private lateinit var buttonCamera: ControlButton
+    private lateinit var buttonBlurBackground: ControlButton
+
+    private var isEnableBlurBackground:Boolean = false
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        this.layoutParams?.width = LayoutParams.MATCH_PARENT
+        this.layoutParams?.height = LayoutParams.MATCH_PARENT
+        initView()
+        registerCameraObserver()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        subscribeStateJob?.cancel()
+    }
+
+    private fun registerCameraObserver() {
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            DeviceStore.shared().deviceState.cameraStatus.collect { cameraStatus ->
+                val isCameraOpened = (cameraStatus == DeviceStatus.ON)
+                buttonCamera.imageView.isActivated = isCameraOpened
+            }
+        }
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.callview_function_view_video_inviting, this)
+        buttonCancel = findViewById(R.id.cb_cancel)
+        buttonCamera = findViewById(R.id.cb_camera)
+        buttonSwitchCamera = findViewById(R.id.cb_switch_camera)
+        buttonBlurBackground = findViewById(R.id.cb_blur)
+
+        buttonSwitchCamera.visibility = VISIBLE
+        buttonCamera.visibility = VISIBLE
+
+        val isCameraOpened = DeviceStore.shared().deviceState.cameraStatus.value == DeviceStatus.ON
+        buttonCamera.imageView.isActivated = isCameraOpened
+        initViewListener()
+    }
+
+    private fun initViewListener() {
+        buttonCancel.setOnClickListener {
+            buttonCancel.imageView.roundPercent = 1.0f
+            buttonCancel.imageView.setBackgroundColor(ContextCompat.getColor(context, R.color.callview_button_bg_red))
+            ImageLoader.loadGif(context, buttonCancel.imageView, R.drawable.callview_hangup_loading)
+            disableButton(buttonCamera)
+            disableButton(buttonSwitchCamera)
+            disableButton(buttonBlurBackground)
+            CallStore.shared.hangup(null)
+        }
+        buttonSwitchCamera.setOnClickListener {
+            if (!buttonSwitchCamera.isEnabled) {
+                return@setOnClickListener
+            }
+            val isFrontCamera = DeviceStore.shared().deviceState.isFrontCamera.value
+            DeviceStore.shared().switchCamera(!isFrontCamera)
+        }
+        buttonCamera.setOnClickListener {
+            if (!buttonCamera.isEnabled) {
+                return@setOnClickListener
+            }
+            val isCameraOpened = (DeviceStore.shared().deviceState.cameraStatus.value == DeviceStatus.ON)
+            buttonCamera.imageView.isActivated = !isCameraOpened
+            buttonSwitchCamera.imageView.isActivated = !isCameraOpened
+            buttonBlurBackground.imageView.isActivated = !isCameraOpened
+
+            if (isCameraOpened) {
+                DeviceStore.shared().closeLocalCamera()
+                buttonCamera.textView.text = context.resources.getString(R.string.callview_toast_disable_camera)
+            } else {
+                openLocalCamera()
+            }
+        }
+        buttonBlurBackground.setOnClickListener {
+            if (!buttonBlurBackground.isEnabled) {
+                return@setOnClickListener
+            }
+            enableBlurBackground(!isEnableBlurBackground)
+        }
+    }
+
+    private fun disableButton(button: View) {
+        button.isEnabled = false
+        button.alpha = 0.8f
+    }
+
+    private fun enableBlurBackground(enable: Boolean) {
+        Logger.i("setBlurBackground, enable: $enable")
+        val level = if (enable) BLUR_LEVEL_HIGH else BLUR_LEVEL_CLOSE
+        buttonBlurBackground.isActivated = enable
+        isEnableBlurBackground = enable
+        TUICallEngine.createInstance(context).setBlurBackground(level, object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+                Logger.i("setBlurBackground success.")
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                buttonBlurBackground.isActivated = false
+                isEnableBlurBackground = false
+                Logger.e("setBlurBackground failed, errCode: $errCode, errMsg: $errMsg")
+            }
+        })
+    }
+
+    private fun openLocalCamera() {
+        PermissionRequest.requestCameraPermission(context, object : PermissionCallback() {
+            override fun onGranted() {
+                val isFrontCamera = DeviceStore.shared().deviceState.isFrontCamera.value
+                DeviceStore.shared().openLocalCamera(isFrontCamera, null)
+                buttonCamera.textView.text = context.resources.getString(R.string.callview_toast_enable_camera)
+            }
+
+            override fun onDenied() {
+                Logger.e("openCamera failed, errMsg: camera permission denied")
+            }
+        })
+    }
+}

+ 129 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/hint/HintView.kt

@@ -0,0 +1,129 @@
+package io.trtc.tuikit.atomicx.callview.public.hint
+
+import android.content.Context
+import android.view.Gravity
+import android.view.View
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.content.ContextCompat
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicxcore.api.call.CallMediaType
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.call.CallParticipantStatus
+import io.trtc.tuikit.atomicxcore.api.device.NetworkQuality
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+class HintView(context: Context) : AppCompatTextView(context) {
+    private var subscribeStateJob: Job? = null
+    private var isFirstShowAccept = true
+    private var callStatus = CallParticipantStatus.None
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        initView()
+        registerObserver()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        subscribeStateJob?.cancel()
+    }
+
+    private fun registerObserver() {
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            launch { observeCallStatus() }
+            launch { observeNetworkQualities() }
+        }
+    }
+
+    private suspend fun observeCallStatus() {
+        CallStore.shared.observerState.selfInfo.collect { selfInfo ->
+            if (callStatus != selfInfo.status) {
+                callStatus = selfInfo.status
+                updateStatusText()
+            }
+        }
+    }
+
+    private suspend fun observeNetworkQualities() {
+        val selfId = CallStore.shared.observerState.selfInfo.value.id
+        CallStore.shared.observerState.networkQualities.collect { networkQualities ->
+            networkQualities.forEach { (userId, quality) ->
+                if (isBadNetwork(quality)) {
+                    text = if (userId == selfId) {
+                        context.getString(R.string.callview_self_network_low_quality)
+                    } else {
+                        context.getString(R.string.callview_other_party_network_low_quality)
+                    }
+                    return@collect
+                }
+                updateStatusText()
+            }
+        }
+    }
+
+    private fun isBadNetwork(quality: NetworkQuality?): Boolean {
+        return quality == NetworkQuality.BAD || quality == NetworkQuality.VERY_BAD
+                || quality == NetworkQuality.DOWN
+    }
+
+    private fun initView() {
+        setTextColor(ContextCompat.getColor(context, R.color.callview_color_white))
+        gravity = Gravity.CENTER
+
+        val activeCall = CallStore.shared.observerState.activeCall.value
+        val isGroupCall = activeCall.inviteeIds.size >= 2
+        text = if (isGroupCall) {
+            if (selfIsCaller()) {
+                context.getString(R.string.callview_wait_response)
+            } else {
+                context.getString(R.string.callview_wait_accept_group)
+            }
+        } else {
+            updateStatusText()
+        }
+    }
+
+    private fun updateSingleCallWaitingText(): String {
+        val mediaType = CallStore.shared.observerState.activeCall.value.mediaType
+        return if (selfIsCaller()) {
+            context.getString(R.string.callview_waiting_accept)
+        } else {
+            if (CallMediaType.Video == mediaType) {
+                context.getString(R.string.callview_invite_video_call)
+            } else {
+                context.getString(R.string.callview_invite_audio_call)
+            }
+        }
+    }
+
+    private fun updateStatusText(): String {
+        val isGroupCall = CallStore.shared.observerState.allParticipants.value.size >= 2
+        val self = CallStore.shared.observerState.selfInfo.value.copy()
+        if (isGroupCall && CallParticipantStatus.Accept == self.status) {
+            visibility = View.GONE
+            return ""
+        }
+
+        if (self.status == CallParticipantStatus.Waiting) {
+            return updateSingleCallWaitingText()
+        }
+
+        text = ""
+        if (self.status == CallParticipantStatus.Accept && selfIsCaller() && isFirstShowAccept) {
+            text = context.getString(R.string.callview_accept_single)
+            postDelayed({
+                isFirstShowAccept = false
+            }, 2000)
+        }
+        return text.toString()
+    }
+
+    private fun selfIsCaller(): Boolean {
+        val selfId = CallStore.shared.observerState.selfInfo.value.id
+        val callerId = CallStore.shared.observerState.activeCall.value.inviterId
+        return selfId == callerId
+    }
+}

+ 52 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/hint/TimerView.kt

@@ -0,0 +1,52 @@
+package io.trtc.tuikit.atomicx.callview.public.hint
+
+import android.content.Context
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.content.ContextCompat
+import com.tencent.qcloud.tuicore.util.DateTimeUtil
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.call.CallParticipantStatus
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+class TimerView(context: Context) : AppCompatTextView(context) {
+    private var subscribeStateJob: Job? = null
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        initView()
+        registerActiveCallObserver()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        subscribeStateJob?.cancel()
+    }
+
+    private fun registerActiveCallObserver() {
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            CallStore.shared.observerState.activeCall.collect { activeCall ->
+                updateDurationView(activeCall.duration)
+            }
+        }
+    }
+
+    private fun initView() {
+        setTextColor(ContextCompat.getColor(context, R.color.callview_color_white))
+        val duration = CallStore.shared.observerState.activeCall.value.duration
+        updateDurationView(duration)
+    }
+
+    private fun updateDurationView(time: Long) {
+        val self = CallStore.shared.observerState.selfInfo.value
+        if (self.status == CallParticipantStatus.Accept) {
+            text = DateTimeUtil.formatSecondsTo00(time.toInt())
+            visibility = VISIBLE
+        } else {
+            visibility = GONE
+        }
+    }
+}

+ 119 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/callview/public/multi/MultiCallWaitingView.kt

@@ -0,0 +1,119 @@
+package io.trtc.tuikit.atomicx.callview.public.multi
+
+import android.content.Context
+import android.view.Gravity
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.utils.widget.ImageFilterView
+import androidx.core.content.ContextCompat
+import com.trtc.tuikit.common.imageloader.ImageLoader
+import com.trtc.tuikit.common.util.ScreenUtil
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.callview.core.common.utils.CallUtils
+import io.trtc.tuikit.atomicxcore.api.call.CallStore
+import io.trtc.tuikit.atomicxcore.api.call.CallParticipantInfo
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+class MultiCallWaitingView(context: Context) : LinearLayout(context) {
+    private val mainScope = MainScope()
+    private var caller: CallParticipantInfo = CallParticipantInfo()
+    private val squareWidth = context.resources.getDimensionPixelOffset(R.dimen.callview_small_image_size)
+    private val defaultMargin = context.resources.getDimensionPixelOffset(R.dimen.callview_small_image_left_margin)
+
+    private lateinit var textWaitingUserName: TextView
+    private lateinit var imageCallerAvatar: ImageFilterView
+    private lateinit var layoutAvatarList: LinearLayout
+
+    init {
+        this.orientation = VERTICAL
+        this.gravity = Gravity.CENTER
+        val self = CallStore.shared.observerState.selfInfo.value.copy()
+        if (CallUtils.isCaller(self.id)) {
+            caller = self
+        } else {
+            val callerId = CallStore.shared.observerState.activeCall.value.inviterId
+            caller.id = callerId
+        }
+    }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        initView()
+        registerParticipantsObserver()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        mainScope.cancel()
+    }
+
+    private fun registerParticipantsObserver() {
+        mainScope.launch {
+            CallStore.shared.observerState.allParticipants.collect { participants ->
+                for (participant in participants) {
+                    if (CallUtils.isCaller(participant.id)) {
+                        ImageLoader.load(context, imageCallerAvatar, participant.avatarUrl, R.drawable.callview_ic_avatar)
+                        textWaitingUserName.text = participant.name
+                    }
+                }
+                updateAvatarListView()
+            }
+        }
+    }
+
+    private fun initView() {
+        imageCallerAvatar = createImageView(caller, ScreenUtil.dip2px(100f), 0, 20)
+        textWaitingUserName = createTextView(caller.name, 48)
+        textWaitingUserName.textSize = 18f
+        layoutAvatarList = LinearLayout(context)
+        layoutAvatarList.layoutParams =
+            LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+
+        addView(imageCallerAvatar)
+        addView(textWaitingUserName)
+        addView(createTextView(context.getString(R.string.callview_invitee_user_list), 24))
+        addView(layoutAvatarList)
+    }
+
+    private fun updateAvatarListView() {
+        layoutAvatarList.removeAllViews()
+        val list = HashSet<CallParticipantInfo>()
+        val allParticipants = CallStore.shared.observerState.allParticipants.value.toSet()
+        val inviterId = CallStore.shared.observerState.activeCall.value.inviterId
+        val selfId = CallStore.shared.observerState.selfInfo.value.id
+        val inviteeList = allParticipants.filter { it.id != inviterId && it.id != selfId}
+        list.addAll(inviteeList)
+
+        for (user in list) {
+            layoutAvatarList.addView(createImageView(user, squareWidth, defaultMargin, 0))
+        }
+    }
+
+    private fun createImageView(user: CallParticipantInfo, width: Int, start: Int, bottom: Int): ImageFilterView {
+        val imageView = ImageFilterView(context)
+        val layoutParams = LayoutParams(width, width)
+        layoutParams.marginStart = start
+        layoutParams.bottomMargin = bottom
+        imageView.round = 12f
+        imageView.scaleType = ImageView.ScaleType.CENTER_CROP
+        imageView.layoutParams = layoutParams
+        ImageLoader.load(context, imageView, user.avatarUrl, R.drawable.callview_ic_avatar)
+        return imageView
+    }
+
+    private fun createTextView(text: String, margin: Int): TextView {
+        val textView = TextView(context)
+        textView.text = text
+        textView.textSize = 12f
+        textView.setTextColor(ContextCompat.getColor(context, R.color.callview_color_white))
+        val param = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+        param.bottomMargin = margin
+        param.gravity = Gravity.CENTER
+        textView.layoutParams = param
+        return textView
+    }
+}

+ 34 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/service/MusicServiceFactory.kt

@@ -0,0 +1,34 @@
+package io.trtc.tuikit.atomicx.karaoke.service
+
+import io.trtc.tuikit.atomicx.karaoke.store.MusicCatalogService
+import com.trtc.tuikit.common.system.ContextProvider
+
+const val PACKAGE_RT_CUBE = "com.tencent.trtc"
+class SongServiceFactory {
+    companion object {
+        private const val ONLINE_MUSIC_SERVICE_CLASS = "com.tencent.liteav.karaoke.service.OnlineMusicService"
+        private const val LOCAL_MUSIC_SERVICE_CLASS = "com.tencent.uikit.app.login.LocalMusicService"
+
+        fun getInstance(): MusicCatalogService? {
+            val serviceClassName = if (isInternalDemo()) {
+                ONLINE_MUSIC_SERVICE_CLASS
+            } else {
+                LOCAL_MUSIC_SERVICE_CLASS
+            }
+
+            return try {
+                val clz = Class.forName(serviceClassName)
+                val constructor = clz.getConstructor()
+                constructor.newInstance() as MusicCatalogService
+            } catch (e: Exception) {
+                e.printStackTrace()
+                null
+            }
+        }
+
+        private fun isInternalDemo(): Boolean {
+            return PACKAGE_RT_CUBE == ContextProvider.getApplicationContext().packageName
+        }
+
+    }
+}

+ 25 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/CallbacksDefine.kt

@@ -0,0 +1,25 @@
+package io.trtc.tuikit.atomicx.karaoke.store
+
+import io.trtc.tuikit.atomicx.karaoke.store.utils.MusicInfo
+
+interface QueryPlayTokenCallBack {
+    fun onSuccess(
+        musicId: String, playToken: String,
+        copyrightedLicenseKey: String?,
+        copyrightedLicenseUrl: String?,
+    )
+
+    fun onFailure(code: Int, desc: String)
+}
+
+
+interface GetSongListCallBack {
+    fun onSuccess(songList: List<MusicInfo>)
+    fun onFailure(code: Int, desc: String)
+}
+
+interface ActionCallback {
+    fun onSuccess(userSig: String)
+
+    fun onFailed(code: Int, msg: String?)
+}

+ 1205 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/KaraokeStore.kt

@@ -0,0 +1,1205 @@
+package io.trtc.tuikit.atomicx.karaoke.store
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager.SongInfo
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager.SongListChangeReason
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager.SongListResult
+import com.tencent.cloud.tuikit.engine.room.TUIRoomDefine
+import com.tencent.cloud.tuikit.engine.room.TUIRoomDefine.GetRoomMetadataCallback
+import com.tencent.cloud.tuikit.engine.room.TUIRoomDefine.RoomDismissedReason
+import com.tencent.cloud.tuikit.engine.room.TUIRoomEngine
+import com.tencent.cloud.tuikit.engine.room.TUIRoomObserver
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.trtc.TRTCCloud
+import com.tencent.trtc.TRTCCloudDef
+import com.tencent.trtc.TRTCCloudDef.TRTCParams
+import com.tencent.trtc.TRTCCloudListener
+import com.tencent.trtc.TXChorusMusicPlayer
+import com.tencent.trtc.TXChorusMusicPlayer.TXChorusRole
+import com.tencent.trtc.txcopyrightedmedia.TXCopyrightedMedia
+import com.trtc.tuikit.common.util.ToastUtil
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.karaoke.service.SongServiceFactory
+import io.trtc.tuikit.atomicx.karaoke.store.utils.LyricsFileReader
+import io.trtc.tuikit.atomicx.karaoke.store.utils.MusicInfo
+import io.trtc.tuikit.atomicx.karaoke.store.utils.PlaybackState
+import io.trtc.tuikit.atomicx.widget.basicwidget.toast.AtomicToast
+import io.trtc.tuikit.atomicxcore.api.live.LiveListStore
+import java.io.File
+import java.io.FileOutputStream
+import java.nio.ByteBuffer
+
+private const val TAG = "KaraokeStore"
+private const val LOCAL_MUSIC_PREFIX = "local_demo"
+private const val KEY_ENABLE_REQUEST_MUSIC = "EnableRequestMusic"
+private const val KEY_ENABLE_SCORE = "EnableScore"
+
+class KaraokeStore private constructor(private val context: Context) {
+    companion object {
+        @Volatile
+        private var instance: KaraokeStore? = null
+
+        @JvmStatic
+        fun getInstance(context: Context): KaraokeStore {
+            return instance ?: synchronized(this) {
+                instance ?: KaraokeStore(context.applicationContext).also { instance = it }
+            }
+        }
+
+        @JvmStatic
+        fun destroyInstance() {
+            instance?.destroy()
+            instance = null
+        }
+    }
+
+    var isAwaitingScoreDisplay = true
+
+    val hostPitch: LiveData<Int> get() = _hostPitch
+    val hostScore: LiveData<Int> get() = _hostScore
+    val currentPlayingSong: LiveData<SongInfo> get() = _currentPlayingSong
+    val isRoomOwner: LiveData<Boolean> get() = _isRoomOwner
+    val songCatalog: LiveData<List<MusicInfo>> get() = _songCatalog
+    val songQueue: LiveData<List<SongInfo>> get() = _songQueue
+    val currentTrack: LiveData<TXChorusMusicPlayer.TXChorusMusicTrack> get() = _currentAudioTrack
+    val playbackProgressMs: LiveData<Long> get() = _playbackProgressMs
+    val songDurationMs: LiveData<Long> get() = _songDurationMs
+    val playbackState: LiveData<PlaybackState> get() = _playbackState
+    val isDisplayFloatView: LiveData<Boolean> get() = _isDisplayFloatView
+    val pitchList: LiveData<List<TXChorusMusicPlayer.TXReferencePitch>> get() = _pitchList
+    val songLyrics: LiveData<List<TXChorusMusicPlayer.TXLyricLine>> get() = _songLyrics
+    val currentScore: LiveData<Int> get() = _currentScore
+    val averageScore: LiveData<Int> get() = _averageScore
+    val currentPitch: LiveData<Int> get() = _currentPitch
+    val publishVolume: LiveData<Int> get() = _publishVolume
+    val playoutVolume: LiveData<Int> get() = _playoutVolume
+    val songPitch: LiveData<Float> get() = _songPitch
+    val enableScore: LiveData<Boolean> get() = _enableScore
+    val isRoomDismissed: LiveData<Boolean> get() = _isRoomDismissed
+    private val songListManager: TUISongListManager = TUIRoomEngine.sharedInstance().songListManager
+    private val trtcCloud: TRTCCloud = TUIRoomEngine.sharedInstance().trtcCloud
+    private val gson = Gson()
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private var musicCatalogService: MusicCatalogService? = null
+    private var chorusPlayer: TXChorusMusicPlayer? = null
+    private lateinit var userId: String
+    private var ownerId: String? = null
+    private var isFullScreenUIMode = false
+    private var _isManualStop = false
+    private var _isSwitchingToNext = false
+    private var _isCurrentSongRemoved = false
+    private var _loadingMusicId: String? = null
+
+    private val _hostPitch = MutableLiveData(0)
+    private val _hostScore = MutableLiveData(-1)
+    private val _currentPlayingSong = MutableLiveData<SongInfo>()
+    private val _isRoomOwner = MutableLiveData(false)
+    private val _songCatalog = MutableLiveData<List<MusicInfo>>(emptyList())
+    private val _songQueue = MutableLiveData<List<SongInfo>>(emptyList())
+    private val _currentAudioTrack = MutableLiveData(TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong)
+    private val _playbackProgressMs = MutableLiveData(0L)
+    private val _songDurationMs = MutableLiveData(0L)
+    private val _playbackState = MutableLiveData(PlaybackState.IDLE)
+    private val _isDisplayFloatView = MutableLiveData(true)
+    private val _pitchList = MutableLiveData<List<TXChorusMusicPlayer.TXReferencePitch>>(emptyList())
+    private val _songLyrics = MutableLiveData<List<TXChorusMusicPlayer.TXLyricLine>>(emptyList())
+    private val _currentScore = MutableLiveData(-1)
+    private val _averageScore = MutableLiveData(0)
+    private val _currentPitch = MutableLiveData(0)
+    private val _publishVolume = MutableLiveData(60)
+    private val _playoutVolume = MutableLiveData(95)
+    private val _songPitch = MutableLiveData(0.0F)
+    private val _enableScore = MutableLiveData(true)
+    private val _isRoomDismissed = MutableLiveData(false)
+
+    fun init(roomId: String, isOwner: Boolean) {
+        Log.d(TAG, "init: roomId=$roomId, isOwner=$isOwner")
+        if (chorusPlayer != null) return
+        _isRoomOwner.value = isOwner
+        ownerId = LiveListStore.shared().liveState.currentLive.value.liveOwner.userID
+        userId = TUIRoomEngine.getSelfInfo().userId
+        musicCatalogService = SongServiceFactory.getInstance()
+        setupChorusPlayer(roomId)
+        copyAllAssetsToStorage()
+        fetchRoomMetadata()
+        getWaitingList()
+        loadMusicCatalog()
+        addObserver()
+        if (isOwner) {
+            setScoringEnabled(enableScore.value == true)
+            applyDefaultAudioEffects()
+        }
+    }
+
+    fun destroy() {
+        Log.d(TAG, "destroy: called")
+        stopPlayback()
+        _isRoomDismissed.value = true
+        _songQueue.value = emptyList()
+        _currentPlayingSong.value = SongInfo()
+        _playbackProgressMs.value = 0L
+        _songLyrics.value = emptyList()
+        _pitchList.value = emptyList()
+        _currentScore.value = 0
+        _playbackState.value = PlaybackState.IDLE
+        _isManualStop = true
+        _isSwitchingToNext = false
+        _isCurrentSongRemoved = false
+        _loadingMusicId = null
+        _averageScore.value = 100
+        _currentAudioTrack.value = TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong
+        removeObserver()
+        chorusPlayer?.destroy()
+        chorusPlayer = null
+    }
+
+    fun addObserver() {
+        Log.d(TAG, "addObserver")
+        songListManager.addObserver(songListObserver)
+        TUIRoomEngine.sharedInstance().addObserver(roomEngineObserver)
+        TUIRoomEngine.sharedInstance().trtcCloud.setAudioFrameListener(audioFrameListener)
+    }
+
+    fun removeObserver() {
+        Log.d(TAG, "removeObserver")
+        songListManager.removeObserver(songListObserver)
+        TUIRoomEngine.sharedInstance().removeObserver(roomEngineObserver)
+        TUIRoomEngine.sharedInstance().trtcCloud.setAudioFrameListener(null)
+    }
+
+    fun enableRequestMusic(enable: Boolean) {
+        Log.d(TAG, "enableRequestMusic: enable=$enable")
+        if (_isDisplayFloatView.value != enable) {
+            val metadata = hashMapOf(KEY_ENABLE_REQUEST_MUSIC to enable.toString())
+            TUIRoomEngine.sharedInstance().setRoomMetadataByAdmin(metadata, null)
+            if (!enable) {
+                clearAllSongs()
+                _songQueue.value = emptyList()
+                _currentPlayingSong.value = SongInfo()
+                _playbackProgressMs.value = 0L
+                _songLyrics.value = emptyList()
+                _pitchList.value = emptyList()
+                _currentScore.value = 0
+                _playbackState.value = PlaybackState.IDLE
+                _averageScore.value = 100
+                _currentAudioTrack.value =
+                    TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong
+                _isManualStop = true
+                stopPlayback()
+            }
+        }
+    }
+
+    private fun loadMusicByLeadSinger() {
+        Log.d(TAG, "loadMusicByLeadSinger: isOwner=${isRoomOwner.value}, queueSize=${songQueue.value?.size}")
+        if (isRoomOwner.value == false) return
+        val songQueueValue = songQueue.value
+        if (songQueueValue.isNullOrEmpty()) {
+            _playbackState.value = PlaybackState.IDLE
+            _loadingMusicId = null
+            _isCurrentSongRemoved = false
+            return
+        }
+        val firstSong = songQueueValue.first()
+        val songId = firstSong.songId
+        if (!songId.isNullOrEmpty()) {
+            _isCurrentSongRemoved = false
+            _loadingMusicId = songId
+            Log.d(TAG, "loadMusicByLeadSinger: loading songId=$songId, songName=${firstSong.songName}")
+            loadMusic(songId)
+        }
+    }
+
+    private fun loadMusic(musicId: String) {
+        Log.d(TAG, "loadMusic: musicId=$musicId")
+        if (musicId.startsWith(LOCAL_MUSIC_PREFIX)) {
+            loadLocalDemoMusic(musicId)
+        } else loadCopyrightedMusic(musicId)
+    }
+
+    private fun loadLocalDemoMusic(musicId: String) {
+        Log.d(TAG, "loadLocalDemoMusic: musicId=$musicId")
+        val musicInfo = findSongInCatalog(musicId) ?: return
+        val params = TXChorusMusicPlayer.TXChorusExternalMusicParams().apply {
+            this.musicId = musicInfo.musicId
+            musicUrl = musicInfo.originalUrl
+            accompanyUrl = musicInfo.accompanyUrl
+            encryptBlockLength = 0
+            isEncrypted = false
+        }
+        chorusPlayer?.loadExternalMusic(params)
+    }
+
+    private fun loadCopyrightedMusic(musicId: String) {
+        Log.d(TAG, "loadCopyrightedMusic: musicId=$musicId")
+        musicCatalogService?.queryPlayToken(musicId, userId, object : QueryPlayTokenCallBack {
+            override fun onSuccess(
+                musicId: String,
+                playToken: String,
+                copyrightedLicenseKey: String?,
+                copyrightedLicenseUrl: String?,
+            ) {
+                Log.d(TAG, "loadCopyrightedMusic: queryPlayToken success, musicId=$musicId")
+                val params = TXChorusMusicPlayer.TXChorusCopyrightedMusicParams().apply {
+                    this.musicId = musicId
+                    this.playToken = playToken
+                    this.copyrightedLicenseKey = copyrightedLicenseKey
+                    this.copyrightedLicenseUrl = copyrightedLicenseUrl
+                }
+                chorusPlayer?.loadMusic(params)
+            }
+
+            override fun onFailure(code: Int, desc: String) {
+                Log.e(TAG, "loadCopyrightedMusic: queryPlayToken failed, code=$code, desc=$desc")
+                onKaraokeError(code, desc)
+            }
+        })
+    }
+
+    fun loadMusicCatalog() {
+        Log.d(TAG, "loadMusicCatalog")
+        musicCatalogService?.getSongList(object : GetSongListCallBack {
+            override fun onSuccess(songList: List<MusicInfo>) {
+                Log.d(TAG, "loadMusicCatalog: success, count=${songList.size}")
+                _songCatalog.postValue(songList)
+            }
+
+            override fun onFailure(code: Int, desc: String) {
+                Log.e(TAG, "loadMusicCatalog: failed, code=$code, desc=$desc")
+                onKaraokeError(code, desc)
+            }
+        })
+    }
+
+    fun setChorusRole(roomId: String, chorusRole: TXChorusRole) {
+        Log.d(TAG, "setChorusRole: roomId=$roomId, role=$chorusRole")
+        val robotID = "${roomId}_bgm"
+        musicCatalogService?.generateUserSig(robotID, object : ActionCallback {
+            override fun onSuccess(robotSig: String) {
+                Log.d(TAG, "setChorusRole: generateUserSig success")
+                val params = TRTCParams().apply {
+                    this.sdkAppId = TUILogin.getSdkAppId()
+                    strRoomId = roomId
+                    userId = robotID
+                    userSig = robotSig
+                }
+                chorusPlayer?.setChorusRole(chorusRole, params)
+            }
+
+            override fun onFailed(code: Int, msg: String?) {
+                Log.e(TAG, "setChorusRole: generateUserSig failed, code=$code")
+                onKaraokeError(code, msg)
+            }
+
+        })
+
+    }
+
+    fun startPlayback() {
+        Log.d(TAG, "startPlayback: isOwner=${isRoomOwner.value}")
+        if (isRoomOwner.value == true) {
+            chorusPlayer?.start()
+            switchMusicTrack(TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong, true)
+        }
+    }
+
+    fun stopPlayback() {
+        Log.d(TAG, "stopPlayback: isOwner=${isRoomOwner.value}")
+        if (isRoomOwner.value == true) {
+            chorusPlayer?.stop()
+        }
+    }
+
+    fun pausePlayback() {
+        Log.d(TAG, "pausePlayback: isOwner=${isRoomOwner.value}")
+        if (isRoomOwner.value == true) {
+            chorusPlayer?.pause()
+        }
+    }
+
+    fun resumePlayback() {
+        Log.d(TAG, "resumePlayback: isOwner=${isRoomOwner.value}")
+        if (isRoomOwner.value == true) {
+            chorusPlayer?.resume()
+        }
+    }
+
+    fun switchMusicTrack(
+        trackType: TXChorusMusicPlayer.TXChorusMusicTrack,
+        isInitial: Boolean = false
+    ) {
+        Log.d(TAG, "switchMusicTrack: trackType=$trackType, isInitial=$isInitial")
+        if (isRoomOwner.value != true) return
+
+        val songId = songQueue.value?.firstOrNull()?.songId ?: return
+        val media = TXCopyrightedMedia.instance()
+        media.init()
+        val hasOrigin = !media.genMusicURI(songId, 0, "audio/hi").isNullOrEmpty()
+        val hasAccompany = !media.genMusicURI(songId, 1, "audio/hi").isNullOrEmpty()
+
+        val finalTrack = if (isInitial) {
+            if (hasOrigin) TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong
+            else if (hasAccompany) TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusAccompaniment
+            else null
+        } else {
+            val isTargetAvailable = if (trackType == TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong) hasOrigin else hasAccompany
+
+            if (!isTargetAvailable) {
+                AtomicToast.show(context, context.getString(R.string.karaoke_cant_switch_tracks), AtomicToast.Style.ERROR)
+                return
+            }
+            trackType
+        }
+
+        finalTrack?.let {
+            chorusPlayer?.switchMusicTrack(it)
+            _currentAudioTrack.value = it
+        }
+    }
+
+    fun setPlayoutVolume(volume: Int?) {
+        Log.d(TAG, "setPlayoutVolume: volume=$volume")
+        volume?.let {
+            chorusPlayer?.setPlayoutVolume(it)
+            _playoutVolume.value = it
+        }
+    }
+
+    fun setPublishVolume(volume: Int?) {
+        Log.d(TAG, "setPublishVolume: volume=$volume")
+        volume?.let {
+            chorusPlayer?.setPublishVolume(it)
+            _publishVolume.value = it
+        }
+    }
+
+    fun setMusicPitch(pitch: Float?) {
+        Log.d(TAG, "setMusicPitch: pitch=$pitch")
+        pitch?.let {
+            chorusPlayer?.setMusicPitch(it)
+            _songPitch.value = it
+        }
+    }
+
+    fun addSong(song: SongInfo) {
+        Log.d(TAG, "addSong: songId=${song.songId}, songName=${song.songName}")
+        songListManager.addSong(listOf(song), object : TUIRoomDefine.ActionCallback {
+            override fun onSuccess() {
+                Log.d(TAG, "addSong: success")
+            }
+
+            override fun onError(
+                code: TUICommonDefine.Error?,
+                message: String?,
+            ) {
+                Log.e(TAG, "addSong: error, code=${code?.value}, msg=$message")
+                onKaraokeError(code?.value, message)
+            }
+
+        })
+    }
+
+    fun removeSong(song: SongInfo) {
+        Log.d(TAG, "removeSong: songId=${song.songId}")
+        songListManager.removeSong(listOf(song.songId), object : TUIRoomDefine.ActionCallback {
+            override fun onSuccess() {
+                Log.d(TAG, "removeSong: success")
+            }
+
+            override fun onError(
+                code: TUICommonDefine.Error?,
+                message: String?,
+            ) {
+                Log.e(TAG, "removeSong: error, code=${code?.value}, msg=$message")
+                onKaraokeError(code?.value, message)
+            }
+
+        })
+    }
+
+    fun clearAllSongs() {
+        Log.d(TAG, "clearAllSongs: queueSize=${_songQueue.value?.size}")
+        val currentQueue = _songQueue.value.orEmpty()
+        if (currentQueue.isEmpty()) {
+            return
+        }
+        songListManager.removeSong( currentQueue.map { it.songId }, object : TUIRoomDefine.ActionCallback {
+            override fun onSuccess() {
+                Log.d(TAG, "clearAllSongs: success")
+            }
+
+            override fun onError(
+                code: TUICommonDefine.Error?,
+                message: String?,
+            ) {
+                Log.e(TAG, "clearAllSongs: error, code=${code?.value}")
+                onKaraokeError(code?.value, message)
+            }
+        })
+    }
+
+    fun setNextSong(targetSongId: String) {
+        Log.d(TAG, "setNextSong: targetSongId=$targetSongId")
+        songListManager.setNextSong(targetSongId, object : TUIRoomDefine.ActionCallback {
+            override fun onSuccess() {
+                Log.d(TAG, "setNextSong: success")
+            }
+
+            override fun onError(
+                code: TUICommonDefine.Error?,
+                message: String?,
+            ) {
+                Log.e(TAG, "setNextSong: error, code=${code?.value}")
+                onKaraokeError(code?.value, message)
+            }
+        })
+    }
+
+    fun playNextSong() {
+        val currentQueue = _songQueue.value.orEmpty()
+        Log.d(TAG, "playNextSong: called, isRoomOwner=${_isRoomOwner.value}, queueSize=${currentQueue.size}, isSwitchingToNext=$_isSwitchingToNext, currentPlaying=${_currentPlayingSong.value?.songName}")
+        if (_isRoomOwner.value != true) {
+            Log.d(TAG, "playNextSong: not room owner, set IDLE")
+            _playbackState.value = PlaybackState.IDLE
+            return
+        }
+        if (currentQueue.isEmpty()) {
+            Log.d(TAG, "playNextSong: song queue is empty, skip playNextSong")
+            _playbackState.value = PlaybackState.IDLE
+            return
+        }
+        if (_isSwitchingToNext) {
+            Log.d(TAG, "playNextSong: already switching, skip")
+            return
+        }
+        _isSwitchingToNext = true
+        Log.d(TAG, "playNextSong: start switching to next song, will remove first: ${currentQueue.firstOrNull()?.songName}")
+
+        songListManager.playNextSong(object : TUIRoomDefine.ActionCallback {
+            override fun onSuccess() {
+                Log.d(TAG, "playNextSong: SDK playNextSong success")
+            }
+
+            override fun onError(
+                code: TUICommonDefine.Error?,
+                message: String?,
+            ) {
+                Log.e(TAG, "playNextSong: SDK playNextSong error: $message")
+                _isSwitchingToNext = false
+                onKaraokeError(code?.value, message)
+            }
+        })
+    }
+
+    fun setIsDisplayScoreView(isDisplay: Boolean) {
+        Log.d(TAG, "setIsDisplayScoreView: isDisplay=$isDisplay")
+        isAwaitingScoreDisplay = isDisplay
+    }
+
+    fun setFullScreenUIMode(isFullScreen: Boolean) {
+        Log.d(TAG, "setFullScreenUIMode: isFullScreen=$isFullScreen")
+        this.isFullScreenUIMode = isFullScreen
+    }
+
+    fun updatePlaybackStatus(state: PlaybackState) {
+        Log.d(TAG, "updatePlaybackStatus: state=$state, currentState=${_playbackState.value}")
+        if (_playbackState.value == PlaybackState.STOP) {
+            _playbackState.value = state
+        }
+    }
+
+    fun setScoringEnabled(enable: Boolean) {
+        Log.d(TAG, "setScoringEnabled: enable=$enable")
+        _enableScore.value = enable
+        val metadata = hashMapOf(KEY_ENABLE_SCORE to enable.toString())
+        TUIRoomEngine.sharedInstance().setRoomMetadataByAdmin(metadata, null)
+    }
+
+    fun updateSongCatalog(selectedList: List<MusicInfo>) {
+        Log.d(TAG, "updateSongCatalog: count=${selectedList.size}")
+        _songCatalog.value = selectedList
+    }
+
+    private fun setupChorusPlayer(roomId: String) {
+        Log.d(TAG, "setupChorusPlayer: roomId=$roomId, isOwner=${_isRoomOwner.value}")
+        chorusPlayer = TXChorusMusicPlayer.create(trtcCloud, roomId, chorusMusicObserver)
+        val role = if (_isRoomOwner.value == true) {
+            TXChorusRole.TXChorusRoleLeadSinger
+        } else {
+            TXChorusRole.TXChorusRoleAudience
+        }
+        setChorusRole(roomId, role)
+    }
+
+    private fun getWaitingList() {
+        Log.d(TAG, "getWaitingList")
+        val allSongsAccumulator = mutableListOf<SongInfo>()
+        fetchNextPage(null, allSongsAccumulator, false)
+    }
+
+    private fun fetchWaitingListAndLoadFirst() {
+        Log.d(TAG, "fetchWaitingListAndLoadFirst: clearing loadingMusicId=$_loadingMusicId")
+        _loadingMusicId = null
+        val allSongsAccumulator = mutableListOf<SongInfo>()
+        fetchNextPage(null, allSongsAccumulator, true)
+    }
+
+    private fun fetchNextPage(cursor: String?, currentAccumulator: MutableList<SongInfo>, loadFirstAfterFetch: Boolean) {
+        val count = 20
+        songListManager.getWaitingList(cursor, count, object : TUISongListManager.SongListCallback {
+            override fun onSuccess(result: SongListResult?) {
+                if (result == null) {
+                    _songQueue.value = currentAccumulator
+                    if (loadFirstAfterFetch) {
+                        Log.d(TAG, "fetchNextPage: fetch complete (null result), loading first song")
+                        loadMusicByLeadSinger()
+                    }
+                    return
+                }
+                if (!result.songList.isNullOrEmpty()) {
+                    currentAccumulator.addAll(result.songList)
+                }
+                val nextCursor = result.cursor
+                val hasMoreData = !nextCursor.isNullOrEmpty()
+
+                if (hasMoreData) {
+                    fetchNextPage(nextCursor, currentAccumulator, loadFirstAfterFetch)
+                } else {
+                    Log.i(TAG, "finished fetching all songs. total count: ${currentAccumulator.size}, songs=${currentAccumulator.map { it.songName }}")
+                    _songQueue.value = currentAccumulator
+                    if (loadFirstAfterFetch) {
+                        Log.d(TAG, "fetchNextPage: fetch complete, loading first song: ${currentAccumulator.firstOrNull()?.songName}")
+                        loadMusicByLeadSinger()
+                    }
+                }
+            }
+
+            override fun onError(code: TUICommonDefine.Error?, msg: String?) {
+                onKaraokeError(code?.value, msg)
+                if (loadFirstAfterFetch) {
+                    loadMusicByLeadSinger()
+                }
+            }
+        })
+    }
+
+    private fun fetchRoomMetadata() {
+        Log.d(TAG, "fetchRoomMetadata")
+        TUIRoomEngine.sharedInstance().getRoomMetadata(
+            listOf(KEY_ENABLE_SCORE, KEY_ENABLE_REQUEST_MUSIC), object : GetRoomMetadataCallback {
+                override fun onSuccess(map: HashMap<String?, String?>?) {
+                    Log.d(TAG, "fetchRoomMetadata: success, map=$map")
+                    map?.let {
+                        if (isRoomOwner.value == true) {
+                            if (_enableScore.value == false) {
+                                setScoringEnabled(true)
+                            }
+                        } else {
+                            _enableScore.value = it[KEY_ENABLE_SCORE]?.toBoolean() ?: true
+                            _isDisplayFloatView.value =
+                                it[KEY_ENABLE_REQUEST_MUSIC]?.toBoolean() ?: true
+                        }
+                    }
+                }
+
+                override fun onError(error: TUICommonDefine.Error?, message: String) {
+                    Log.e(TAG, "fetchRoomMetadata: error, code=${error?.value}, msg=$message")
+                    onError(error, message)
+                }
+            })
+    }
+
+    private fun findSongInCatalog(musicId: String): MusicInfo? {
+        return songCatalog.value?.firstOrNull { it.musicId == musicId }
+    }
+
+    private fun findSongLyricPath(musicId: String): String {
+        return _songCatalog.value?.find { it.musicId == musicId }?.lyricUrl ?: ""
+    }
+
+    private fun getSongInfoById(musicId: String): SongInfo {
+        val songInQueue = _songQueue.value?.find { it.songId == musicId }
+        if (songInQueue != null) {
+            return songInQueue
+        }
+        return SongInfo().apply {
+            this.songId = musicId
+        }
+    }
+
+    private val songListObserver: TUISongListManager.Observer =
+        object : TUISongListManager.Observer() {
+            override fun onWaitingListChanged(
+                reason: SongListChangeReason?,
+                changedSongs: MutableList<SongInfo?>?,
+            ) {
+                Log.d(TAG, "onWaitingListChanged: reason=$reason, changedCount=${changedSongs?.size}")
+                updateSongQueue(reason, changedSongs)
+                handlePlayOperation(reason, changedSongs)
+            }
+        }
+
+    private fun updateSongQueue(
+        reason: SongListChangeReason?,
+        changedSongs: MutableList<SongInfo?>?,
+    ) {
+        val currentQueue = _songQueue.value.orEmpty().toMutableList()
+        if (reason == null || changedSongs.isNullOrEmpty()) {
+            return
+        }
+        Log.d(
+            TAG,
+            "updateSongQueue: reason=${reason.name}, changedSongs=${changedSongs.mapNotNull { it?.songName }}, currentQueue=${currentQueue.map { it.songName }}"
+        )
+        when (reason) {
+            SongListChangeReason.ADD -> {
+                changedSongs.filterNotNull().forEach { newSong ->
+                    if (currentQueue.none { it.songId == newSong.songId }) {
+                        currentQueue.add(newSong)
+                    }
+                }
+                _songQueue.value = currentQueue
+            }
+
+            SongListChangeReason.REMOVE -> {
+                val removeSongIds = changedSongs.filterNotNull().map { it.songId }
+                val songsToRemove = currentQueue.filter { it.songId in removeSongIds }
+                currentQueue.removeAll(songsToRemove)
+                _songQueue.value = currentQueue
+                Log.d(TAG, "updateSongQueue REMOVE: removed=${songsToRemove.map { it.songName }}, remaining=${currentQueue.map { it.songName }}")
+            }
+
+            SongListChangeReason.ORDER_CHANGED -> {
+                if (_isSwitchingToNext) {
+                    val songToRemove = changedSongs.filterNotNull().firstOrNull()
+                    if (songToRemove != null) {
+                        currentQueue.removeAll { it.songId == songToRemove.songId }
+                        _songQueue.value = currentQueue
+                        Log.d(TAG, "updateSongQueue ORDER_CHANGED (switching): removed ${songToRemove.songName}, remaining=${currentQueue.map { it.songName }}")
+                        return
+                    }
+                }
+                
+                val songToMoveUp = changedSongs.filterNotNull().firstOrNull()
+                if (songToMoveUp == null) {
+                    Log.d(TAG, "updateSongQueue ORDER_CHANGED: no song to move")
+                    return
+                }
+                
+                currentQueue.removeAll { it.songId == songToMoveUp.songId }
+                val targetIndex = minOf(1, currentQueue.size)
+                currentQueue.add(targetIndex, songToMoveUp)
+                _songQueue.value = currentQueue
+                Log.d(TAG, "updateSongQueue ORDER_CHANGED: moved ${songToMoveUp.songName} to index $targetIndex, newQueue=${currentQueue.map { it.songName }}")
+            }
+
+            SongListChangeReason.UNKNOWN -> {
+                val newQueue = changedSongs.filterNotNull()
+                Log.d(TAG, "updateSongQueue UNKNOWN: using server queue=${newQueue.map { it.songName }}")
+                _songQueue.value = newQueue
+                return
+            }
+        }
+
+        val currentPlayingId = _currentPlayingSong.value?.songId
+        if (!currentPlayingId.isNullOrEmpty()) {
+            val finalQueue = _songQueue.value.orEmpty()
+            val songInNewQueue = finalQueue.find { it.songId == currentPlayingId }
+            if (songInNewQueue != null) {
+                _currentPlayingSong.postValue(songInNewQueue)
+                Log.d(TAG, "Sync current playing info from new queue: ${songInNewQueue.songName}")
+            }
+        }
+        Log.d(TAG, "updateSongQueue: final songQueue=${_songQueue.value?.map { it.songName }}")
+    }
+
+    private fun handlePlayOperation(
+        reason: SongListChangeReason?,
+        changedSongs: MutableList<SongInfo?>?,
+    ) {
+        if (reason == null || changedSongs.isNullOrEmpty()) {
+            return
+        }
+        Log.d(TAG, "handlePlayOperation: reason=$reason, changedSongs=${changedSongs.mapNotNull { it?.songName }}")
+        when (reason) {
+            SongListChangeReason.ADD -> {
+                val isNeedLoadMusic = songQueue.value?.size == changedSongs.size
+                Log.d(TAG, "handlePlayOperation ADD: queueSize=${songQueue.value?.size}, changedSize=${changedSongs.size}, isNeedLoadMusic=$isNeedLoadMusic")
+                if (isNeedLoadMusic) loadMusicByLeadSinger()
+            }
+
+            SongListChangeReason.REMOVE -> {
+                val currentPlayingId = _currentPlayingSong.value?.songId
+                val isCurrentSongAffected = changedSongs.any { it?.songId == currentPlayingId }
+                Log.d(TAG, "handlePlayOperation REMOVE: currentPlayingId=$currentPlayingId, isCurrentSongAffected=$isCurrentSongAffected, isSwitchingToNext=$_isSwitchingToNext")
+
+                if (_isRoomOwner.value == true) {
+                    if (_isSwitchingToNext) {
+                        Log.d(TAG, "handlePlayOperation REMOVE: switching to next, refetch queue and load first song")
+                        _isCurrentSongRemoved = true
+                        stopPlayback()
+                        
+                        fetchWaitingListAndLoadFirst()
+                    } else if (isCurrentSongAffected) {
+                        Log.d(TAG, "handlePlayOperation REMOVE: current song removed manually")
+                        _isCurrentSongRemoved = true
+                        stopPlayback()
+                        loadMusicByLeadSinger()
+                    }
+                }
+            }
+
+            SongListChangeReason.ORDER_CHANGED -> {
+                if (_isSwitchingToNext && _isRoomOwner.value == true) {
+                    val currentPlayingId = _currentPlayingSong.value?.songId
+                    val newQueueFirst = _songQueue.value?.firstOrNull()
+                    Log.d(TAG, "handlePlayOperation ORDER_CHANGED: isSwitching=true, currentPlayingId=$currentPlayingId, newQueueFirst=${newQueueFirst?.songName}")
+                    
+                    if (newQueueFirst != null && newQueueFirst.songId != currentPlayingId) {
+                        Log.d(TAG, "handlePlayOperation ORDER_CHANGED: switching to next, refetch queue and load first song")
+                        _isCurrentSongRemoved = true
+                        stopPlayback()
+                        fetchWaitingListAndLoadFirst()
+                    }
+                }
+            }
+            
+            SongListChangeReason.UNKNOWN -> {
+                if (_isSwitchingToNext && _isRoomOwner.value == true) {
+                    val currentPlayingId = _currentPlayingSong.value?.songId
+                    val newQueue = changedSongs.filterNotNull()
+                    val isCurrentSongStillInQueue = newQueue.any { it.songId == currentPlayingId }
+                    Log.d(TAG, "handlePlayOperation UNKNOWN: isSwitching=true, currentPlayingId=$currentPlayingId, isCurrentSongStillInQueue=$isCurrentSongStillInQueue, newQueue=${newQueue.map { it.songName }}")
+                    
+                    if (!isCurrentSongStillInQueue || (newQueue.isNotEmpty() && newQueue.first().songId != currentPlayingId)) {
+                        Log.d(TAG, "handlePlayOperation UNKNOWN: switching to next, refetch queue and load first song")
+                        _isCurrentSongRemoved = true
+                        stopPlayback()
+                        fetchWaitingListAndLoadFirst()
+                    }
+                }
+            }
+        }
+    }
+
+    private val roomEngineObserver: TUIRoomObserver = object : TUIRoomObserver() {
+        override fun onRoomDismissed(
+            roomId: String?,
+            reason: RoomDismissedReason?,
+        ) {
+            Log.d(TAG, "onRoomDismissed: roomId=$roomId, reason=$reason")
+            destroy()
+        }
+
+        override fun onRoomMetadataChanged(key: String?, value: String?) {
+            Log.d(TAG, "onRoomMetadataChanged: key=$key, value=$value")
+            when (key) {
+                KEY_ENABLE_SCORE -> {
+                    _enableScore.value = value?.toBoolean() ?: true
+                }
+
+                KEY_ENABLE_REQUEST_MUSIC -> {
+                    _isDisplayFloatView.value = value?.toBoolean() ?: true
+                }
+            }
+        }
+    }
+
+    private val audioFrameListener: TRTCCloudListener.TRTCAudioFrameListener =
+        object : TRTCCloudListener.TRTCAudioFrameListener {
+
+            private var lastSentJsonData: String? = null
+            private var sendCounter = 0
+            val userIdKey = "u"
+            val pitchKey = "p"
+            val scoreKey = "s"
+            val avgScoreKey = "a"
+
+            override fun onCapturedAudioFrame(p0: TRTCCloudDef.TRTCAudioFrame?) {
+
+            }
+
+            override fun onLocalProcessedAudioFrame(frame: TRTCCloudDef.TRTCAudioFrame?) {
+                frame ?: return
+
+                if (_isRoomOwner.value != true || (_playbackState.value != PlaybackState.START && _playbackState.value != PlaybackState.RESUME)) {
+                    lastSentJsonData = null
+                    sendCounter = 0
+                    return
+                }
+
+                val dataMap = mutableMapOf<String, Any>()
+                dataMap[userIdKey] = userId
+
+                _currentPitch.value?.let { pitch -> dataMap[pitchKey] = pitch }
+                _currentScore.value?.let { score -> dataMap[scoreKey] = score }
+                _averageScore.value?.let { avgScore -> dataMap[avgScoreKey] = avgScore }
+
+                if (dataMap.size > 1) {
+                    val currentJsonString = gson.toJson(dataMap)
+                    if (currentJsonString != lastSentJsonData) {
+                        lastSentJsonData = currentJsonString
+                        sendCounter = 5
+                    }
+                }
+
+                if (sendCounter > 0 && lastSentJsonData != null) {
+                    val dataBytes = lastSentJsonData!!.toByteArray(Charsets.UTF_8)
+                    frame.extraData = dataBytes
+                    sendCounter--
+                }
+            }
+
+            override fun onRemoteUserAudioFrame(
+                frame: TRTCCloudDef.TRTCAudioFrame?,
+                userId: String?,
+            ) {
+                frame?.extraData ?: return
+                userId ?: return
+                if (frame.extraData.isEmpty()) return
+                if (ownerId == null) return
+
+                try {
+                    val jsonString = String(frame.extraData, Charsets.UTF_8)
+                    val type = object : TypeToken<Map<String, Any>>() {}.type
+                    val dataMap: Map<String, Any> = gson.fromJson(jsonString, type)
+
+                    val itemUserId = dataMap[userIdKey] as? String
+                    if (itemUserId != ownerId) {
+                        return
+                    }
+
+                    (dataMap[pitchKey] as? Double)?.toInt()?.let { pitch ->
+                        if (_hostPitch.value != pitch) {
+                            _hostPitch.postValue(pitch)
+                        }
+                    }
+
+                    (dataMap[scoreKey] as? Double)?.toInt()?.let { score ->
+                        if (_hostScore.value != score) {
+                            _hostScore.postValue(score)
+                        }
+                    }
+
+                    (dataMap[avgScoreKey] as? Double)?.toInt()?.let { avgScore ->
+                        if (_averageScore.value != avgScore) {
+                            _averageScore.postValue(avgScore)
+                        }
+                    }
+
+                } catch (e: Exception) {
+                }
+            }
+
+            override fun onMixedPlayAudioFrame(p0: TRTCCloudDef.TRTCAudioFrame?) {
+            }
+
+            override fun onMixedAllAudioFrame(p0: TRTCCloudDef.TRTCAudioFrame?) {
+            }
+
+            override fun onVoiceEarMonitorAudioFrame(p0: TRTCCloudDef.TRTCAudioFrame?) {
+            }
+        }
+
+    private val chorusMusicObserver: TXChorusMusicPlayer.ITXChorusPlayerListener =
+        object : TXChorusMusicPlayer.ITXChorusPlayerListener {
+            override fun onChorusMusicLoadSucceed(
+                musicId: String,
+                lyricList: List<TXChorusMusicPlayer.TXLyricLine>,
+                pitchList: List<TXChorusMusicPlayer.TXReferencePitch>,
+            ) {
+                Log.d(TAG, "onChorusMusicLoadSucceed: musicId=$musicId, lyricCount=${lyricList.size}, pitchCount=${pitchList.size}, loadingMusicId=$_loadingMusicId, isSwitching=$_isSwitchingToNext, isRemoved=$_isCurrentSongRemoved")
+                
+                if (_isCurrentSongRemoved) {
+                    Log.d(TAG, "onChorusMusicLoadSucceed: ignoring callback because current song was removed")
+                    return
+                }
+                
+                if (_loadingMusicId != null && _loadingMusicId != musicId) {
+                    Log.d(TAG, "onChorusMusicLoadSucceed: ignoring stale callback, expected=$_loadingMusicId, got=$musicId")
+                    return
+                }
+                
+                val queueFirst = _songQueue.value?.firstOrNull()
+                if (queueFirst != null && queueFirst.songId != musicId) {
+                    Log.d(TAG, "onChorusMusicLoadSucceed: musicId mismatch with queue first, reloading. queueFirst=${queueFirst.songId}")
+                    loadMusicByLeadSinger()
+                    return
+                }
+
+                if (musicId.startsWith(LOCAL_MUSIC_PREFIX)) {
+                    val musicPathTest = findSongLyricPath(musicId)
+                    _songLyrics.value = LyricsFileReader().parseLyricInfo(musicPathTest)
+                } else {
+                    _songLyrics.value = lyricList
+                    _pitchList.value = pitchList
+                }
+                _currentScore.postValue(-1)
+                _averageScore.postValue(0)
+                _hostPitch.postValue(0)
+                _hostScore.postValue(-1)
+                _currentPitch.postValue(0)
+                _currentPlayingSong.postValue(getSongInfoById(musicId))
+                _loadingMusicId = null
+                _isCurrentSongRemoved = false
+                _isSwitchingToNext = false
+                startPlayback()
+            }
+
+            override fun onChorusError(error: TXChorusMusicPlayer.TXChorusError, errMsg: String) {
+                Log.e(TAG, "onChorusError: error=$error, errMsg=$errMsg")
+                if (error == TXChorusMusicPlayer.TXChorusError.TXChorusErrorMusicLoadFailed) {
+                    val content = context.getString(R.string.karaoke_music_loading_error)
+                    AtomicToast.show(context,"$content (${error.ordinal})", AtomicToast.Style.ERROR)
+                    playNextSong()
+                }
+            }
+
+            override fun onNetworkQualityUpdated(userId: Int, upQuality: Int, downQuality: Int) {}
+
+            override fun onChorusRequireLoadMusic(musicId: String) {
+                Log.d(TAG, "onChorusRequireLoadMusic: musicId=$musicId")
+                loadMusic(musicId)
+            }
+
+            override fun onChorusMusicLoadProgress(musicId: String, progress: Float) {
+                Log.d(TAG, "onChorusMusicLoadProgress: musicId=$musicId, progress=$progress")
+            }
+
+            override fun onChorusStarted() {
+                Log.d(TAG, "onChorusStarted")
+                _playbackState.value = PlaybackState.START
+                isAwaitingScoreDisplay = true
+                if (isRoomOwner.value == true) {
+                    enableReverb(true)
+                }
+            }
+
+            override fun onChorusPaused() {
+                Log.d(TAG, "onChorusPaused: isOwner=${_isRoomOwner.value}, queueSize=${_songQueue.value?.size}")
+                if (_isRoomOwner.value == false && _songQueue.value.orEmpty().isEmpty()) {
+                    return
+                }
+                _playbackState.value = PlaybackState.PAUSE
+            }
+
+            override fun onChorusResumed() {
+                Log.d(TAG, "onChorusResumed")
+                _playbackState.value = PlaybackState.RESUME
+            }
+
+            override fun onChorusStopped() {
+                Log.d(TAG, "onChorusStopped: isManualStop=$_isManualStop, isSwitchingToNext=$_isSwitchingToNext, isCurrentSongRemoved=$_isCurrentSongRemoved, isRoomOwner=${_isRoomOwner.value}, queueSize=${_songQueue.value?.size}, currentPlaying=${_currentPlayingSong.value?.songName}")
+
+                if (_isManualStop) {
+                    _isManualStop = false
+                    _playbackState.value = PlaybackState.IDLE
+                    return
+                }
+                if (_isCurrentSongRemoved) {
+                    Log.d(TAG, "onChorusStopped: current song already removed, skip playNextSong")
+                    _playbackState.value = PlaybackState.STOP
+                    return
+                }
+                if (_isSwitchingToNext) {
+                    Log.d(TAG, "onChorusStopped: switching in progress, skip playNextSong")
+                    _playbackState.value = PlaybackState.STOP
+                    return
+                }
+                if (_isRoomOwner.value == true) {
+                    enableReverb(false)
+                }
+                _playbackState.value = PlaybackState.STOP
+                val shouldDelayForScore =
+                    isFullScreenUIMode && isAwaitingScoreDisplay && enableScore.value == true
+
+                if (shouldDelayForScore) {
+                    mainHandler.postDelayed({
+                        _songQueue.value?.size?.let {
+                            if (it <= 1) {
+                                _playbackState.value = PlaybackState.IDLE
+                            }
+                            playNextSong()
+                        }
+                    }, 5000)
+                } else {
+                    playNextSong()
+                }
+            }
+
+            override fun onMusicProgressUpdated(progressMs: Long, durationMs: Long) {
+                _playbackProgressMs.value = progressMs
+                _songDurationMs.value = durationMs
+                if (isRoomOwner.value == false) {
+                    if (isAwaitingScoreDisplay && progressMs / 1000 != durationMs / 1000) {
+                        isAwaitingScoreDisplay = false
+                    } else if (!isAwaitingScoreDisplay && progressMs / 1000 == durationMs / 1000) {
+                        isAwaitingScoreDisplay = true
+                    }
+                }
+            }
+
+            override fun onVoicePitchUpdated(pitch: Int, hasVoice: Boolean, progressMs: Long) {
+                _currentPitch.value = if (pitch == -1) 0 else pitch
+            }
+
+            override fun onVoiceScoreUpdated(
+                currentScore: Int,
+                averageScore: Int,
+                currentLine: Int,
+            ) {
+                Log.d(TAG, "onVoiceScoreUpdated: current=$currentScore, avg=$averageScore, line=$currentLine")
+                _currentScore.value = currentScore
+                _averageScore.value = averageScore
+            }
+
+            override fun shouldDecryptAudioData(audioData: ByteBuffer) {
+
+            }
+        }
+
+    private fun applyDefaultAudioEffects() {
+        Log.d(TAG, "applyDefaultAudioEffects")
+        enableDsp()
+        enableHIFI()
+        enableAIECModel2()
+        enableAIEC()
+        enableAI()
+    }
+
+    private fun callTRTCExperimentalApi(api: String, params: Map<String, Any>) {
+        val json = gson.toJson(mapOf("api" to api, "params" to params))
+        trtcCloud.callExperimentalAPI(json)
+    }
+
+    private fun enableDsp() {
+        val params = mapOf(
+            "configs" to listOf(
+                mapOf(
+                    "key" to "Liteav.Audio.common.dsp.version", "value" to "2", "default" to "1"
+                )
+            )
+        )
+        callTRTCExperimentalApi("setPrivateConfig", params)
+    }
+
+    private fun enableHIFI() {
+        val params = mapOf(
+            "configs" to listOf(
+                mapOf(
+                    "key" to "Liteav.Audio.common.smart.3a.strategy.flag",
+                    "value" to "16",
+                    "default" to "1"
+                )
+            )
+        )
+        callTRTCExperimentalApi("setPrivateConfig", params)
+    }
+
+    private fun enableAIECModel2() {
+        val params = mapOf(
+            "configs" to listOf(
+                mapOf(
+                    "key" to "Liteav.Audio.common.ai.ec.model.type",
+                    "value" to "2",
+                    "default" to "2"
+                )
+            )
+        )
+        callTRTCExperimentalApi("setPrivateConfig", params)
+    }
+
+    private fun enableAIEC() {
+        val params = mapOf(
+            "configs" to listOf(
+                mapOf(
+                    "key" to "Liteav.Audio.common.enable.ai.ec.module",
+                    "value" to "1",
+                    "default" to "1"
+                )
+            )
+        )
+        callTRTCExperimentalApi("setPrivateConfig", params)
+    }
+
+    private fun enableAI() {
+        val params = mapOf(
+            "configs" to listOf(
+                mapOf(
+                    "key" to "Liteav.Audio.common.ai.module.enabled",
+                    "value" to "1",
+                    "default" to "1"
+                )
+            )
+        )
+        callTRTCExperimentalApi("setPrivateConfig", params)
+    }
+
+    private fun enableReverb(enable: Boolean) {
+        Log.d(TAG, "enableReverb: enable=$enable")
+        val params = mapOf(
+            "enable" to enable,
+            "RoomSize" to 60,
+            "PreDelay" to 20,
+            "Reverberance" to 40,
+            "Damping" to 50,
+            "ToneLow" to 30,
+            "ToneHigh" to 100,
+            "WetGain" to -3,
+            "DryGain" to 0,
+            "StereoWidth" to 40,
+            "WetOnly" to false
+        )
+        callTRTCExperimentalApi("setCustomReverbParams", params)
+    }
+
+
+    private fun copyAllAssetsToStorage() {
+        val assetFiles = listOf(
+            "nuannuan_bz.mp3", "nuannuan_yc.mp3", "nuannuan_lrc.vtt"
+        )
+        assetFiles.forEach { copyAssetToFile(it) }
+    }
+
+    private fun copyAssetToFile(assetName: String) {
+        val savePath = ContextCompat.getExternalFilesDirs(context, null)[0].absolutePath
+        val destinationFile = File(savePath, assetName)
+        if (destinationFile.exists()) return
+        try {
+            destinationFile.parentFile?.mkdirs()
+            context.assets.open(assetName).use { inputStream ->
+                FileOutputStream(destinationFile).use { outputStream ->
+                    inputStream.copyTo(outputStream)
+                }
+            }
+        } catch (e: Exception) {
+            e.printStackTrace()
+        }
+    }
+
+    private fun onKaraokeError(code: Int?, desc: String?) {
+        val errorCode = code ?: -1
+        val errorMessage = desc ?: "Unknown error"
+        Log.e(TAG, "errorCode: $errorCode, errorMessage: $errorMessage")
+
+        mainHandler.post {
+            val frequencyLimit = -2
+            if (errorCode == frequencyLimit) {
+                val content = context.getString(R.string.common_client_error_freq_limit)
+                AtomicToast.show(context,"$content (${errorCode})", AtomicToast.Style.ERROR)
+            } else {
+                AtomicToast.show(context,"$errorMessage ($errorCode)", AtomicToast.Style.ERROR)
+            }
+        }
+    }
+}

+ 7 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/MusicCatalogService.kt

@@ -0,0 +1,7 @@
+package io.trtc.tuikit.atomicx.karaoke.store
+
+abstract class MusicCatalogService {
+    abstract fun getSongList(callback: GetSongListCallBack)
+    abstract fun generateUserSig(userId: String, callback: ActionCallback)
+    open fun queryPlayToken(musicId: String, userId: String, callback: QueryPlayTokenCallBack) {}
+}

+ 21 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/utils/KaraokeTypes.kt

@@ -0,0 +1,21 @@
+package io.trtc.tuikit.atomicx.karaoke.store.utils
+
+enum class PlaybackState {
+    IDLE, START, PAUSE, RESUME, STOP
+}
+
+data class MusicInfo(
+    var musicId: String = "",
+    var musicName: String = "",
+    var artist: String = "",
+    var lyricUrl: String = "",
+    var originalUrl: String = "",
+    var accompanyUrl: String = "",
+    var coverUrl: String = "",
+    var duration: Int = 0,
+)
+
+enum class LyricAlign {
+    RIGHT,
+    CENTER
+}

+ 89 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/store/utils/LyricsFileReader.kt

@@ -0,0 +1,89 @@
+package io.trtc.tuikit.atomicx.karaoke.store.utils
+
+import android.util.Log
+import com.tencent.trtc.TXChorusMusicPlayer
+import java.io.BufferedReader
+import java.io.File
+import java.io.FileInputStream
+import java.io.InputStreamReader
+
+class LyricsFileReader {
+    companion object {
+        private const val TAG = "LyricsFileReader"
+        private val TIME_PATTERN = """(\d{2}):(\d{2}):(\d{2}).(\d{3})""".toRegex()
+        private val WORD_PATTERN = """\<(\d+),(\d+),(\d+)\>""".toRegex()
+    }
+
+    fun parseLyricInfo(path: String): List<TXChorusMusicPlayer.TXLyricLine>? {
+        val lyricFile = File(path).takeIf { it.exists() && it.length() > 0 } ?: run {
+            Log.w(TAG, "Lyric file not found or empty: $path")
+            return null
+        }
+
+        return runCatching {
+            FileInputStream(lyricFile).use { input ->
+                BufferedReader(InputStreamReader(input)).use { reader ->
+                    buildList {
+                        var line: String?
+                        while (reader.readLine().also { line = it } != null) {
+                            line?.takeIf { TIME_PATTERN.containsMatchIn(it) }?.let { timeLine ->
+                                val lyricsLineInfo = parseLyricTimeLine(timeLine)
+                                val lyricString = reader.readLine()
+                                val updatedLineInfo = parseLyricWords(lyricString, lyricsLineInfo)
+                                add(updatedLineInfo)
+                            }
+                        }
+                    }
+                }
+            }
+        }.onFailure { e ->
+            Log.e(TAG, "Failed to parse lyric file: ${e.message}", e)
+        }.getOrNull()
+    }
+
+    private fun parseLyricTimeLine(lineString: String): TXChorusMusicPlayer.TXLyricLine {
+        val (startTime, endTime) = lineString.split(" --> ").map { dateToMilliseconds(it) }
+        val lyricLine = TXChorusMusicPlayer.TXLyricLine()
+        lyricLine.startTimeMs = startTime
+        lyricLine.durationMs = endTime - startTime
+        lyricLine.characterArray = null
+        return lyricLine
+    }
+
+    private fun parseLyricWords(
+        lineString: String?,
+        lineInfo: TXChorusMusicPlayer.TXLyricLine,
+    ): TXChorusMusicPlayer.TXLyricLine {
+        lineString ?: return lineInfo
+
+        val wordMatches = WORD_PATTERN.findAll(lineString).toList()
+        val words = lineString.split(WORD_PATTERN)
+
+        val wordInfoList = wordMatches.mapIndexed { index, matchResult ->
+            TXChorusMusicPlayer.TXChorusLyricCharacter().apply {
+                startTimeMs = matchResult.groupValues[1].toLong()
+                durationMs = matchResult.groupValues[2].toLong()
+                utf8Character = words.getOrNull(index + 1) ?: ""
+            }
+        }
+        val result = TXChorusMusicPlayer.TXLyricLine()
+        result.startTimeMs = lineInfo.startTimeMs
+        result.durationMs = lineInfo.durationMs
+        result.characterArray = wordInfoList
+        return result
+    }
+
+    private fun dateToMilliseconds(inputString: String): Long {
+        return TIME_PATTERN.matchEntire(inputString)?.let { match ->
+            match.groupValues.let { groups ->
+                groups[1].toLong() * 3600000L +
+                        groups[2].toLong() * 60000 +
+                        groups[3].toLong() * 1000 +
+                        groups[4].toLong()
+            }
+        } ?: run {
+            Log.e(TAG, "Invalid time format: $inputString")
+            -1
+        }
+    }
+}

+ 443 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/KaraokeControlView.kt

@@ -0,0 +1,443 @@
+package io.trtc.tuikit.atomicx.karaoke.view
+
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.lifecycle.Observer
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager
+import io.trtc.tuikit.atomicx.karaoke.store.KaraokeStore
+import io.trtc.tuikit.atomicx.karaoke.store.utils.LyricAlign
+import io.trtc.tuikit.atomicx.karaoke.store.utils.PlaybackState
+import com.tencent.trtc.TXChorusMusicPlayer
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+import io.trtc.tuikit.atomicx.widget.basicwidget.popover.AtomicPopover
+
+
+class KaraokeControlView @JvmOverloads constructor(
+    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
+) : ConstraintLayout(context, attrs, defStyleAttr) {
+    var isAudienceFirstEnterRoom = true
+    private lateinit var store: KaraokeStore
+    private lateinit var lyricView: LyricView
+    private lateinit var pitchView: PitchView
+    private lateinit var textScore: TextView
+    private lateinit var textSeg: TextView
+    private lateinit var imageNext: ImageView
+    private lateinit var imagePause: ImageView
+    private lateinit var imageSetting: ImageView
+    private lateinit var textMusicName: TextView
+    private lateinit var layoutRoot: FrameLayout
+    private lateinit var layoutTime: LinearLayout
+    private lateinit var layoutScore: FrameLayout
+    private lateinit var textMusicAuthor: TextView
+    private lateinit var textPlayProgress: TextView
+    private lateinit var textPlayDuration: TextView
+    private lateinit var textRequesterName: TextView
+    private lateinit var layoutFunction: LinearLayout
+    private lateinit var imageEnableOriginal: ImageView
+    private lateinit var layoutRequestMusic: LinearLayout
+    private lateinit var textAudienceWaitingTips: TextView
+    private lateinit var textAudiencePauseTips: TextView
+    private lateinit var imageRequesterAvatar: AtomicAvatar
+    private lateinit var songRequestPanel: SongRequestPanel
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private val isOwnerObserver = Observer<Boolean> { updateOwnerSpecificViews() }
+    private val currentTrackObserver = Observer(this::onCurrentTrackChanged)
+    private val currentMusicObserver = Observer(this::onCurrentMusicChanged)
+    private val playQueueObserver = Observer(this::onPlayQueueChanged)
+    private val durationObserver = Observer(this::onDurationChanged)
+    private val playbackStateObserver = Observer(this::onPlaybackStateChanged)
+    private val progressObserver = Observer(this::onProgressChanged)
+    private val pitchListObserver = Observer(this::onPitchListChanged)
+    private val currentPitchObserver = Observer(this::onCurrentPitchChanged)
+    private val currentScoreObserver = Observer(this::onCurrentScoreChanged)
+    private val enableScoreObserver = Observer(this::onEnableScoreChanged)
+    private val remoteScoresObserver = Observer(this::onRemoteScoresChanged)
+    private val remotePitchesObserver = Observer(this::onRemotePitchesChanged)
+
+    init {
+        LayoutInflater.from(context).inflate(R.layout.karaoke_control_view, this, true)
+        bindViewId()
+    }
+
+    fun init(roomId: String, isOwner: Boolean) {
+        store = KaraokeStore.getInstance(context)
+        store.init(roomId, isOwner)
+        songRequestPanel = SongRequestPanel(context, store, false)
+        initViews()
+        addObservers()
+    }
+
+    fun release() {
+        removeObservers()
+        KaraokeStore.destroyInstance()
+        mainHandler.removeCallbacksAndMessages(null)
+    }
+
+    fun showSongRequestPanel() {
+        songRequestPanel.show()
+    }
+
+    private fun bindViewId() {
+        layoutRoot = findViewById(R.id.fl_root)
+        layoutTime = findViewById(R.id.ll_time_bar)
+        imagePause = findViewById(R.id.iv_pause)
+        textScore = findViewById(R.id.tv_score)
+        layoutScore = findViewById(R.id.fl_score)
+        textSeg = findViewById(R.id.tv_seg)
+        imageNext = findViewById(R.id.iv_next)
+        textMusicName = findViewById(R.id.tv_music_name)
+        imageSetting = findViewById(R.id.iv_setting)
+        textPlayProgress = findViewById(R.id.progress)
+        textMusicAuthor = findViewById(R.id.tv_music_artist)
+        layoutFunction = findViewById(R.id.ll_right_icons)
+        imageEnableOriginal = findViewById(R.id.iv_original)
+        textPlayDuration = findViewById(R.id.duration)
+        textRequesterName = findViewById(R.id.tv_requester_name)
+        imageRequesterAvatar = findViewById(R.id.iv_user_avatar)
+        textAudienceWaitingTips = findViewById(R.id.tv_waiting_tips)
+        textAudiencePauseTips = findViewById(R.id.tv_pause_tips)
+        layoutRequestMusic = findViewById(R.id.ll_order_music)
+    }
+
+    private fun initViews() {
+        initPitchView()
+        initLyricView()
+        initClickListeners()
+    }
+
+    private fun initClickListeners() {
+        imagePause.setOnClickListener {
+            if (store.playbackState.value == PlaybackState.START || store.playbackState.value == PlaybackState.RESUME) {
+                store.pausePlayback()
+            } else {
+                store.resumePlayback()
+            }
+        }
+
+        imageSetting.setOnClickListener {
+            val atomicPopover = AtomicPopover(context)
+            val karaokeSettingPanel = KaraokeSettingPanel(context)
+            karaokeSettingPanel.init(store)
+            karaokeSettingPanel.setOnBackButtonClickListener(object :
+                KaraokeSettingPanel.OnBackButtonClickListener {
+                override fun onClick() {
+                    atomicPopover.dismiss()
+                }
+            })
+            atomicPopover.setContent(karaokeSettingPanel)
+            atomicPopover.show()
+        }
+        layoutRequestMusic.setOnClickListener { view ->
+            view.post {
+                if (songRequestPanel.isShowing) {
+                    return@post
+                }
+                songRequestPanel.show()
+            }
+        }
+        imageNext.setOnClickListener {
+            store.playNextSong()
+            store.setIsDisplayScoreView(false)
+        }
+        imageEnableOriginal.setOnClickListener {
+            if (store.currentTrack.value == TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong) {
+                store.switchMusicTrack(TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusAccompaniment)
+            } else {
+                store.switchMusicTrack(TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong)
+            }
+        }
+    }
+
+    private fun addObservers() {
+        store.playbackProgressMs.observeForever(progressObserver)
+        store.playbackState.observeForever(playbackStateObserver)
+        store.songDurationMs.observeForever(durationObserver)
+        store.isRoomOwner.observeForever(isOwnerObserver)
+        store.currentTrack.observeForever(currentTrackObserver)
+        store.currentPlayingSong.observeForever(currentMusicObserver)
+        store.songQueue.observeForever(playQueueObserver)
+        store.pitchList.observeForever(pitchListObserver)
+        store.currentPitch.observeForever(currentPitchObserver)
+        store.currentScore.observeForever(currentScoreObserver)
+        store.enableScore.observeForever(enableScoreObserver)
+        store.hostScore.observeForever(remoteScoresObserver)
+        store.hostPitch.observeForever(remotePitchesObserver)
+    }
+
+    private fun removeObservers() {
+        store.playbackProgressMs.removeObserver(progressObserver)
+        store.playbackState.removeObserver(playbackStateObserver)
+        store.songDurationMs.removeObserver(durationObserver)
+        store.isRoomOwner.removeObserver(isOwnerObserver)
+        store.currentTrack.removeObserver(currentTrackObserver)
+        store.currentPlayingSong.removeObserver(currentMusicObserver)
+        store.currentTrack.removeObserver(currentTrackObserver)
+        store.pitchList.removeObserver(pitchListObserver)
+        store.currentPitch.removeObserver(currentPitchObserver)
+        store.currentScore.removeObserver(currentScoreObserver)
+        store.enableScore.removeObserver(enableScoreObserver)
+        store.hostScore.removeObserver(remoteScoresObserver)
+        store.hostPitch.removeObserver(remotePitchesObserver)
+    }
+
+    private fun onProgressChanged(progress: Long) {
+        lyricView.setPlayProgress(progress)
+        pitchView.setPlayProgress(progress)
+        textPlayProgress.text = formatTime(progress)
+    }
+
+    private fun onDurationChanged(durationMs: Long) {
+        textPlayDuration.text = formatTime(durationMs)
+    }
+
+    override fun onVisibilityChanged(changedView: View, visibility: Int) {
+        super.onVisibilityChanged(changedView, visibility)
+        if (!this::store.isInitialized) {
+            return
+        }
+        if (visibility == VISIBLE) {
+            store.setFullScreenUIMode(true)
+        }
+    }
+
+    private fun onCurrentTrackChanged(currentTrack: TXChorusMusicPlayer.TXChorusMusicTrack) {
+        val resource =
+            if (currentTrack == TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong) R.drawable.karaoke_original_on
+            else R.drawable.karaoke_original_off
+        imageEnableOriginal.setImageResource(resource)
+    }
+
+    private fun onCurrentMusicChanged(currentSong: TUISongListManager.SongInfo) {
+        if (currentSong.songId.isEmpty() || store.songQueue.value?.isEmpty() == true) {
+            textMusicName.text = context.getString(R.string.karaoke_no_song)
+            textMusicAuthor.text = null
+            return
+        }
+        textMusicName.text = currentSong.songName
+        textMusicAuthor.text = "- " + currentSong?.artistName
+    }
+
+    private fun onPlaybackStateChanged(playbackState: PlaybackState) {
+        when (playbackState) {
+            PlaybackState.IDLE -> handleIdleState()
+            PlaybackState.START -> handleStartState()
+            PlaybackState.RESUME -> handleResumeState()
+            PlaybackState.PAUSE -> handlePausedState()
+            PlaybackState.STOP -> handleStoppedState()
+        }
+    }
+
+    private fun onPitchListChanged(pitchList: List<TXChorusMusicPlayer.TXReferencePitch>) {
+        pitchView.setPitchList(pitchList)
+    }
+
+    private fun onCurrentPitchChanged(pitch: Int) {
+        pitchView.setUserPitch(pitch)
+    }
+
+    private fun onCurrentScoreChanged(score: Int) {
+        pitchView.setScore(score)
+    }
+
+    private fun onEnableScoreChanged(enableScore: Boolean) {
+        pitchView.setScoringEnabled(enableScore)
+    }
+
+    private fun onRemoteScoresChanged(score: Int) {
+        if (store.isRoomOwner.value == false) {
+            pitchView.setScore(score)
+        }
+    }
+
+    private fun onRemotePitchesChanged(pitch: Int) {
+        if (store.isRoomOwner.value == false) {
+            pitchView.setUserPitch(pitch)
+        }
+    }
+
+    private fun handleIdleState() {
+        layoutFunction.visibility = GONE
+        layoutScore.visibility = GONE
+        lyricView.visibility = GONE
+        pitchView.visibility = GONE
+        textMusicName.text = context.getString(R.string.karaoke_no_song)
+        textMusicAuthor.text = null
+        onProgressChanged(0)
+        onDurationChanged(0)
+        if (store.isRoomOwner.value == true) {
+            layoutRequestMusic.visibility = VISIBLE
+        } else {
+            updateAudienceWaitingUI()
+        }
+    }
+
+    private fun updateUIForPlayingState() {
+        if (store.isRoomOwner.value == false) {
+            isAudienceFirstEnterRoom = false
+        }
+        layoutScore.visibility = GONE
+        lyricView.visibility = VISIBLE
+        pitchView.visibility = VISIBLE
+        layoutTime.visibility = VISIBLE
+        layoutRequestMusic.visibility = GONE
+        textAudienceWaitingTips.visibility = GONE
+        textAudiencePauseTips.visibility = GONE
+        if (store.isRoomOwner.value == true) {
+            layoutFunction.visibility = VISIBLE
+        }
+        setSongProgressViewsVisible(true)
+        imagePause.setImageResource(R.drawable.karaoke_music_resume)
+        imageNext.setImageResource(R.drawable.karaoke_music_next)
+        imageSetting.setImageResource(R.drawable.karaoke_setting)
+    }
+
+    private fun handleStartState() {
+        updateUIForPlayingState()
+    }
+
+    private fun handleResumeState() {
+        updateUIForPlayingState()
+    }
+
+    private fun handlePausedState() {
+        if (store.isRoomOwner.value == true) {
+            layoutFunction.visibility = VISIBLE
+        } else {
+            updateAudienceWaitingUI()
+        }
+        imagePause.setImageResource(R.drawable.karaoke_music_pause)
+    }
+
+    private fun setSongProgressViewsVisible(isVisible: Boolean) {
+        val visibility = if (isVisible) VISIBLE else GONE
+        textPlayDuration.visibility = visibility
+        textPlayProgress.visibility = visibility
+        textSeg.visibility = visibility
+    }
+
+    private fun handleStoppedState() {
+        layoutFunction.visibility = GONE
+        lyricView.visibility = GONE
+        pitchView.visibility = GONE
+
+        if (store.enableScore.value == true && store.isAwaitingScoreDisplay) {
+            store.songDurationMs.value?.let { duration ->
+                textPlayProgress.text = formatTime(duration)
+            }
+            layoutScore.visibility = VISIBLE
+            textScore.text = store.averageScore.value.toString()
+            imageRequesterAvatar.setContent(
+                AtomicAvatar.AvatarContent.URL(
+                    store.currentPlayingSong.value?.requester?.avatarUrl ?: "",
+                    R.drawable.karaoke_song_cover
+                )
+            )
+            textRequesterName.text = store.currentPlayingSong.value?.requester?.userName
+        } else {
+            store.updatePlaybackStatus(PlaybackState.IDLE)
+        }
+    }
+
+    private fun updateOwnerSpecificViews() {
+        val isOwner = store.isRoomOwner.value == true
+        if (isOwner) {
+            layoutRequestMusic.visibility = VISIBLE
+        } else {
+            updateAudienceWaitingUI()
+        }
+    }
+
+    private fun updateAudienceWaitingUI() {
+        if (store.isRoomOwner.value == true) {
+            textAudienceWaitingTips.visibility = GONE
+            textAudiencePauseTips.visibility = GONE
+            return
+        }
+        val isQueueEmpty = store.songQueue.value.orEmpty().isEmpty()
+        val currentState = store.playbackState.value
+        if (isQueueEmpty) {
+            textAudienceWaitingTips.visibility = VISIBLE
+            textAudiencePauseTips.visibility = GONE
+        } else {
+            if (currentState == PlaybackState.PAUSE && isAudienceFirstEnterRoom) {
+                textAudienceWaitingTips.visibility = GONE
+                textAudiencePauseTips.visibility = VISIBLE
+                layoutTime.visibility = GONE
+                setSongProgressViewsVisible(false)
+                lyricView.visibility = GONE
+                pitchView.visibility = GONE
+            } else {
+                textAudienceWaitingTips.visibility = GONE
+                textAudiencePauseTips.visibility = GONE
+            }
+        }
+    }
+
+    private fun onPlayQueueChanged(list: List<TUISongListManager.SongInfo>) {
+        if (store.isRoomOwner.value == true) {
+            return
+        }
+        updateAudienceWaitingUI()
+        if (list.isEmpty()) {
+            store.updatePlaybackStatus(PlaybackState.IDLE)
+        }
+    }
+
+    private fun formatTime(millis: Long): String {
+        val totalSeconds = millis / 1000
+        val minutes = totalSeconds / 60
+        val seconds = totalSeconds % 60
+        return String.format("%d:%02d", minutes, seconds)
+    }
+
+    fun initPitchView() {
+        if (layoutRoot is ViewGroup) {
+            (layoutRoot as ViewGroup).clipChildren = false
+            (layoutRoot as ViewGroup).clipToPadding = false
+        }
+
+        pitchView = PitchView(context)
+        val width = FrameLayout.LayoutParams.MATCH_PARENT
+        val height =
+            TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 56f, resources.displayMetrics)
+                .toInt()
+        val lp = FrameLayout.LayoutParams(width, height)
+        lp.topMargin =
+            TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 56f, resources.displayMetrics)
+                .toInt()
+        pitchView.layoutParams = lp
+        pitchView.setBackgroundResource(R.drawable.karaoke_pitch_bg)
+        layoutRoot.addView(pitchView)
+        pitchView.visibility = GONE
+    }
+
+    fun initLyricView() {
+        lyricView = LyricView(context, store)
+        val width = FrameLayout.LayoutParams.MATCH_PARENT
+        val height =
+            TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 50f, resources.displayMetrics)
+                .toInt()
+        val lp = FrameLayout.LayoutParams(width, height)
+        lp.topMargin =
+            TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 116f, resources.displayMetrics)
+                .toInt()
+        lyricView.layoutParams = lp
+        layoutRoot.addView(lyricView)
+        lyricView.setLyricAlign(LyricAlign.CENTER)
+        lyricView.setLyricTextSize(18f, 12f)
+        lyricView.visibility = GONE
+    }
+}

+ 405 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/KaraokeFloatingView.kt

@@ -0,0 +1,405 @@
+package io.trtc.tuikit.atomicx.karaoke.view
+
+
+import android.content.Context
+import android.graphics.Color
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.lifecycle.Observer
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager
+import com.tencent.trtc.TXChorusMusicPlayer
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.karaoke.store.KaraokeStore
+import io.trtc.tuikit.atomicx.karaoke.store.utils.PlaybackState
+import io.trtc.tuikit.atomicx.widget.basicwidget.popover.AtomicPopover
+import io.trtc.tuikit.atomicxcore.api.live.CoHostStore
+import io.trtc.tuikit.atomicxcore.api.live.CoHostStore.Companion.create
+import io.trtc.tuikit.atomicxcore.api.live.SeatUserInfo
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+
+class KaraokeFloatingView @JvmOverloads constructor(
+    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
+) : ConstraintLayout(context, attrs, defStyleAttr) {
+    enum class FloatingMode { RIGHT_HALF_MOVE, CENTER_FIXED }
+
+    private lateinit var store: KaraokeStore
+    private lateinit var liveID: String
+    private var coHostStore: CoHostStore? = null
+    private var subscribeStateJob: Job? = null
+    private lateinit var imagePause: ImageView
+    private lateinit var imageNext: ImageView
+    private lateinit var imageRequestMusic: ImageView
+    private lateinit var imageSetting: ImageView
+    private lateinit var imageEnableOriginal: ImageView
+    private lateinit var lyricView: LyricView
+    private lateinit var pitchView: PitchView
+    private lateinit var frameFunction: FrameLayout
+    private lateinit var layoutRoot: LinearLayout
+    private lateinit var songRequestPanel: SongRequestPanel
+    private var parentView: ViewGroup? = null
+    private var mode: FloatingMode = FloatingMode.RIGHT_HALF_MOVE
+    private var isDragging = false
+    private var lastY = 0f
+    private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
+    private var moveRangeTop = 0f
+    private var moveRangeBottom = 0f
+    private val rightMarginPx: Float = 10 * context.resources.displayMetrics.density
+    private val playQueueObserver = Observer(this::onPlayQueueChanged)
+    private val progressObserver = Observer(this::onProgressChanged)
+    private val isDisplayFloatViewObserver = Observer(this::onDisplayFloatViewChanged)
+    private val playbackStateObserver = Observer(this::onPlaybackStateChanged)
+    private val pitchListObserver = Observer(this::onPitchListChanged)
+    private val currentPitchObserver = Observer(this::onCurrentPitchChanged)
+    private val currentScoreObserver = Observer(this::onCurrentScoreChanged)
+    private val enableScoreObserver = Observer(this::onEnableScoreChanged)
+    private val remoteScoresObserver = Observer(this::onRemoteScoresChanged)
+    private val remotePitchesObserver = Observer(this::onRemotePitchesChanged)
+    private val currentTrackObserver = Observer(this::onCurrentTrackChanged)
+
+    init {
+        LayoutInflater.from(context).inflate(R.layout.karaoke_floating_view, this, true)
+        setBackgroundColor(Color.TRANSPARENT)
+        isClickable = true
+        bindViewId()
+    }
+
+    fun init(roomId: String, isOwner: Boolean) {
+        store = KaraokeStore.getInstance(context)
+        liveID = roomId
+        coHostStore = create(roomId)
+        store.init(roomId, isOwner)
+        songRequestPanel = SongRequestPanel(context, store, isOwner)
+        setupDynamicViews()
+        initClickListeners()
+        addObservers()
+    }
+
+    fun release() {
+        removeObservers()
+        KaraokeStore.destroyInstance()
+    }
+
+    fun attachAsFloating(parent: ViewGroup, mode: FloatingMode) {
+        this@KaraokeFloatingView.mode = mode
+        parentView = parent
+        isClickable = true
+        this.visibility = INVISIBLE
+        (this.parent as? ViewGroup)?.removeView(this)
+        parent.addView(
+            this, FrameLayout.LayoutParams(
+                FrameLayout.LayoutParams.WRAP_CONTENT,
+                FrameLayout.LayoutParams.WRAP_CONTENT
+            )
+        )
+        post {
+            updateFloatingLayout()
+            this.visibility = VISIBLE
+        }
+    }
+
+    fun detachFromFloating() {
+        (this.parent as? ViewGroup)?.removeView(this)
+    }
+
+    fun showSongRequestPanel() {
+        songRequestPanel.show()
+    }
+
+    private fun bindViewId() {
+        layoutRoot = findViewById(R.id.ll_root)
+        imagePause = findViewById(R.id.iv_pause)
+        imageNext = findViewById(R.id.iv_next)
+        imageRequestMusic = findViewById(R.id.iv_order_music)
+        imageSetting = findViewById(R.id.iv_setting)
+        imageEnableOriginal = findViewById(R.id.iv_original)
+        frameFunction = findViewById(R.id.fl_function)
+    }
+
+    private fun setupDynamicViews() {
+        if (layoutRoot is ViewGroup) {
+            (layoutRoot as ViewGroup).clipChildren = false
+            (layoutRoot as ViewGroup).clipToPadding = false
+        }
+        pitchView = PitchView(context)
+        val width = TypedValue.applyDimension(
+            TypedValue.COMPLEX_UNIT_DIP, 177f, resources.displayMetrics
+        ).toInt()
+        val height = TypedValue.applyDimension(
+            TypedValue.COMPLEX_UNIT_DIP, 50f, resources.displayMetrics
+        ).toInt()
+        val layoutParams = LinearLayout.LayoutParams(width, height)
+        layoutParams.topMargin = TypedValue.applyDimension(
+            TypedValue.COMPLEX_UNIT_DIP, 20f,
+            resources.displayMetrics
+        ).toInt()
+        pitchView.layoutParams = layoutParams
+        layoutRoot.addView(pitchView, 0)
+        lyricView = LyricView(context, store)
+        val params = LinearLayout.LayoutParams(width, height)
+        lyricView.layoutParams = params
+        val index: Int = layoutRoot.indexOfChild(pitchView)
+        layoutRoot.addView(lyricView, index + 1)
+    }
+
+    private fun initClickListeners() {
+        imagePause.setOnClickListener {
+            if (store.playbackState.value == PlaybackState.START || store.playbackState.value == PlaybackState.RESUME) {
+                store.pausePlayback()
+            } else {
+                store.resumePlayback()
+            }
+        }
+        imageNext.setOnClickListener {
+            store.playNextSong()
+            store.setIsDisplayScoreView(false)
+        }
+        imageRequestMusic.setOnClickListener { view ->
+            view.post {
+                if (songRequestPanel.isShowing) {
+                    return@post
+                }
+                songRequestPanel.show()
+            }
+        }
+        frameFunction.setOnClickListener { view ->
+            view.post {
+                if (songRequestPanel.isShowing) {
+                    return@post
+                }
+                songRequestPanel.show()
+            }
+        }
+        imageSetting.setOnClickListener {
+            val atomicPopover = AtomicPopover(context)
+            val karaokeSettingPanel = KaraokeSettingPanel(context)
+            karaokeSettingPanel.init(store)
+            karaokeSettingPanel.setOnBackButtonClickListener(object :
+                KaraokeSettingPanel.OnBackButtonClickListener {
+                override fun onClick() {
+                    atomicPopover.dismiss()
+                }
+            })
+            atomicPopover.setContent(karaokeSettingPanel)
+            atomicPopover.show()
+        }
+        imageEnableOriginal.setOnClickListener {
+            if (store.currentTrack.value == TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong) {
+                store.switchMusicTrack(TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusAccompaniment)
+            } else {
+                store.switchMusicTrack(TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong)
+            }
+        }
+    }
+
+    private fun addObservers() {
+        store.playbackProgressMs.observeForever(progressObserver)
+        store.songQueue.observeForever(playQueueObserver)
+        store.isDisplayFloatView.observeForever(isDisplayFloatViewObserver)
+        store.playbackState.observeForever(playbackStateObserver)
+        store.pitchList.observeForever(pitchListObserver)
+        store.currentPitch.observeForever(currentPitchObserver)
+        store.currentScore.observeForever(currentScoreObserver)
+        store.enableScore.observeForever(enableScoreObserver)
+        store.hostScore.observeForever(remoteScoresObserver)
+        store.hostPitch.observeForever(remotePitchesObserver)
+        store.currentTrack.observeForever(currentTrackObserver)
+        addConnectionObserver()
+    }
+
+    private fun removeObservers() {
+        store.playbackProgressMs.removeObserver(progressObserver)
+        store.songQueue.removeObserver(playQueueObserver)
+        store.isDisplayFloatView.removeObserver(isDisplayFloatViewObserver)
+        store.playbackState.removeObserver(playbackStateObserver)
+        store.pitchList.removeObserver(pitchListObserver)
+        store.currentPitch.removeObserver(currentPitchObserver)
+        store.currentScore.removeObserver(currentScoreObserver)
+        store.enableScore.removeObserver(enableScoreObserver)
+        store.hostScore.removeObserver(remoteScoresObserver)
+        store.hostPitch.removeObserver(remotePitchesObserver)
+        store.currentTrack.removeObserver(currentTrackObserver)
+        subscribeStateJob?.cancel()
+    }
+
+    fun addConnectionObserver() {
+        subscribeStateJob = CoroutineScope(Dispatchers.Main).launch {
+            coHostStore?.coHostState?.connected?.collect {
+                onConnectedListChanged(it)
+            }
+        }
+    }
+
+    fun onConnectedListChanged(connectedRoomList: List<SeatUserInfo>) {
+        val isConnected = connectedRoomList.any { it.liveID == liveID }
+        if (isConnected) {
+            store.enableRequestMusic(false)
+        } else {
+            store.enableRequestMusic(true)
+        }
+    }
+
+    private fun onProgressChanged(progress: Long) {
+        lyricView.setPlayProgress(progress)
+        pitchView.setPlayProgress(progress)
+    }
+
+    private fun onDisplayFloatViewChanged(isDisplay: Boolean) {
+        layoutRoot.visibility = if (isDisplay) VISIBLE else GONE
+        if (isDisplay && isAttachedToWindow) {
+            post { updateFloatingLayout() }
+        }
+    }
+
+    private fun onPlaybackStateChanged(playbackState: PlaybackState) {
+        if (playbackState == PlaybackState.START) {
+            imagePause.setImageResource(R.drawable.karaoke_music_resume)
+        } else if (playbackState == PlaybackState.RESUME) {
+            imagePause.setImageResource(R.drawable.karaoke_music_resume)
+        } else if (playbackState == PlaybackState.PAUSE) {
+            imagePause.setImageResource(R.drawable.karaoke_music_pause)
+        } else {
+            imagePause.setImageResource(R.drawable.karaoke_music_pause)
+        }
+    }
+
+    private fun onPitchListChanged(pitchList: List<TXChorusMusicPlayer.TXReferencePitch>) {
+        pitchView.setPitchList(pitchList)
+    }
+
+    private fun onCurrentPitchChanged(pitch: Int) {
+        pitchView.setUserPitch(pitch)
+    }
+
+    private fun onCurrentScoreChanged(score: Int) {
+        pitchView.setScore(score)
+    }
+
+    private fun onEnableScoreChanged(enableScore: Boolean) {
+        pitchView.setScoringEnabled(enableScore)
+    }
+
+    private fun onRemoteScoresChanged(score: Int) {
+        if (store.isRoomOwner.value == false) {
+            pitchView.setScore(score)
+        }
+    }
+
+    private fun onRemotePitchesChanged(pitch: Int) {
+        if (store.isRoomOwner.value == false) {
+            pitchView.setUserPitch(pitch)
+        }
+    }
+
+    private fun onCurrentTrackChanged(currentTrack: TXChorusMusicPlayer.TXChorusMusicTrack) {
+        val resource =
+            if (currentTrack == TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong) R.drawable.karaoke_original_on
+            else R.drawable.karaoke_original_off
+        imageEnableOriginal.setImageResource(resource)
+    }
+
+    private fun onPlayQueueChanged(list: List<TUISongListManager.SongInfo>) {
+        val isOwner = store.isRoomOwner.value == true
+        val isQueueEmpty = list.isEmpty()
+
+        val showFunctionBar = isOwner && !isQueueEmpty
+        frameFunction.visibility = if (showFunctionBar) VISIBLE else GONE
+        imageRequestMusic.visibility = if (showFunctionBar) GONE else VISIBLE
+
+        val showLyricAndPitch = !isQueueEmpty
+        lyricView.visibility = if (showLyricAndPitch) VISIBLE else GONE
+        pitchView.visibility = if (showLyricAndPitch) VISIBLE else GONE
+    }
+
+    private fun updateFloatingLayout() {
+        val parent = parentView ?: return
+        val parentW = parent.width
+        val parentH = parent.height
+        val myW = width
+        val myH = height
+
+        if (mode == FloatingMode.RIGHT_HALF_MOVE) {
+            moveRangeTop = parentH / 4f
+            moveRangeBottom = parentH * 3f / 4f - myH
+            this.y = parentH / 2f - myH / 2f
+            this.x = parentW - rightMarginPx - myW
+        } else if (mode == FloatingMode.CENTER_FIXED) {
+            val d110 = context.resources.displayMetrics.density * 110
+            this.y = d110
+            this.x = (parentW - myW) / 2f
+        }
+    }
+
+    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
+        if (ev == null || mode != FloatingMode.RIGHT_HALF_MOVE) return false
+        when (ev.action) {
+            MotionEvent.ACTION_DOWN -> {
+                lastY = ev.rawY
+                isDragging = false
+            }
+
+            MotionEvent.ACTION_MOVE -> {
+                if (abs(ev.rawY - lastY) > touchSlop) {
+                    isDragging = true
+                    return true
+                }
+            }
+        }
+        return false
+    }
+
+    override fun onVisibilityChanged(changedView: View, visibility: Int) {
+        super.onVisibilityChanged(changedView, visibility)
+        if (!this::store.isInitialized) {
+            return
+        }
+        if (visibility == VISIBLE) {
+            store.setFullScreenUIMode(false)
+        }
+    }
+
+    override fun onTouchEvent(event: MotionEvent?): Boolean {
+        if (event == null) return false
+        when (event.action) {
+            MotionEvent.ACTION_DOWN -> {
+                lastY = event.rawY
+                isDragging = false
+                performClick()
+                return true
+            }
+
+            MotionEvent.ACTION_MOVE -> {
+                if (mode != FloatingMode.RIGHT_HALF_MOVE) return false
+                val dy = event.rawY - lastY
+                this.y = (y + dy).coerceIn(moveRangeTop, moveRangeBottom)
+                val parentW = parentView?.width ?: 0
+                this.x = parentW - rightMarginPx - width
+                lastY = event.rawY
+                return true
+            }
+
+            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+                isDragging = false
+                return true
+            }
+        }
+        return super.onTouchEvent(event)
+    }
+
+    override fun performClick(): Boolean {
+        super.performClick()
+        return true
+    }
+}

+ 145 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/KaraokeSettingPanel.kt

@@ -0,0 +1,145 @@
+package io.trtc.tuikit.atomicx.karaoke.view
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.FrameLayout
+import android.widget.SeekBar
+import android.widget.TextView
+import androidx.appcompat.widget.SwitchCompat
+import com.tencent.trtc.TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusAccompaniment
+import com.tencent.trtc.TXChorusMusicPlayer.TXChorusMusicTrack.TXChorusOriginalSong
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.karaoke.store.KaraokeStore
+
+class KaraokeSettingPanel @JvmOverloads constructor(
+    private val context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0,
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+    private lateinit var textPlayoutVolume: TextView
+    private lateinit var textCaptureVolume: TextView
+    private lateinit var textMusicPitch: TextView
+    private lateinit var store: KaraokeStore
+    private var onBackButtonClickListener: OnBackButtonClickListener? = null
+
+    init {
+        LayoutInflater.from(this@KaraokeSettingPanel.context)
+            .inflate(R.layout.karaoke_music_setting_panel, this, true)
+    }
+
+    fun init(store: KaraokeStore) {
+        this@KaraokeSettingPanel.store = store
+        initView()
+    }
+
+    private fun initView() {
+        bindViewId()
+        initFinishView()
+        initPlayoutVolumeView()
+        initCaptureVolumeView()
+        initMusicPitchView()
+        initEnableOriginView()
+        initEnableScoreView()
+    }
+
+    private fun bindViewId() {
+        textCaptureVolume = findViewById(R.id.tv_capture_volume)
+        textPlayoutVolume = findViewById(R.id.tv_playout_volume)
+        textMusicPitch = findViewById(R.id.tv_music_pitch)
+    }
+
+    private fun initEnableOriginView() {
+        val switchOrigin = findViewById<SwitchCompat>(R.id.sc_enable_origin)
+        switchOrigin.isChecked = store.currentTrack.value == TXChorusOriginalSong
+        switchOrigin.setOnCheckedChangeListener { _, isChecked ->
+            if (isChecked) {
+                store.switchMusicTrack(TXChorusOriginalSong)
+            } else {
+                store.switchMusicTrack(TXChorusAccompaniment)
+            }
+            switchOrigin.isChecked = store.currentTrack.value == TXChorusOriginalSong
+        }
+    }
+
+    private fun initEnableScoreView() {
+        val switchScore = findViewById<SwitchCompat>(R.id.sc_enable_score)
+        switchScore.isChecked = store.enableScore.value == true
+        switchScore.setOnCheckedChangeListener { _, enable ->
+            store.setScoringEnabled(enable)
+        }
+    }
+
+    private fun initCaptureVolumeView() {
+        textCaptureVolume.text = store.publishVolume.value.toString()
+        val seekMusicVolume = findViewById<SeekBar>(R.id.sb_capture_volume)
+        seekMusicVolume.progress = store.publishVolume.value ?: 0
+        seekMusicVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+            override fun onProgressChanged(seekBar: SeekBar, i: Int, b: Boolean) {
+                textCaptureVolume.text = i.toString()
+            }
+
+            override fun onStartTrackingTouch(seekBar: SeekBar) {}
+
+            override fun onStopTrackingTouch(seekBar: SeekBar) {
+                store.setPublishVolume(seekBar.progress)
+            }
+        })
+    }
+
+    private fun initPlayoutVolumeView() {
+        textPlayoutVolume.text = store.playoutVolume.value.toString()
+        val seekPlayoutVolume = findViewById<SeekBar>(R.id.sb_playout_volume)
+        seekPlayoutVolume.progress = store.playoutVolume.value ?: 0
+        seekPlayoutVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+            override fun onProgressChanged(seekBar: SeekBar, i: Int, b: Boolean) {
+                textPlayoutVolume.text = i.toString()
+            }
+
+            override fun onStartTrackingTouch(seekBar: SeekBar) {}
+
+            override fun onStopTrackingTouch(seekBar: SeekBar) {
+                store.setPlayoutVolume(seekBar.progress)
+            }
+        })
+    }
+
+    private fun initMusicPitchView() {
+        val seekBar = findViewById<SeekBar>(R.id.sb_music_pitch)
+        val initialPitch = store.songPitch.value ?: 0f
+        var initProgress = ((initialPitch + 1.0f) * 10).toInt()
+        initProgress = initProgress.coerceIn(0, 20)
+        seekBar.progress = initProgress
+
+        textMusicPitch.text = String.format("%.1f", initialPitch)
+
+        seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+            override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
+                val pitch = (progress - 10) * 0.1f
+                textMusicPitch.text = String.format("%.1f", pitch)
+            }
+
+            override fun onStartTrackingTouch(seekBar: SeekBar) {}
+
+            override fun onStopTrackingTouch(seekBar: SeekBar) {
+                val pitch = (seekBar.progress - 10) * 0.1f
+                store.setMusicPitch(pitch)
+            }
+        })
+    }
+
+    private fun initFinishView() {
+        findViewById<TextView>(R.id.tv_finish).setOnClickListener {
+            onBackButtonClickListener?.onClick()
+        }
+    }
+
+    fun setOnBackButtonClickListener(listener: OnBackButtonClickListener?) {
+        onBackButtonClickListener = listener
+    }
+
+    interface OnBackButtonClickListener {
+        fun onClick()
+    }
+}

+ 220 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/LyricView.kt

@@ -0,0 +1,220 @@
+package io.trtc.tuikit.atomicx.karaoke.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.Typeface
+import android.text.TextPaint
+import android.util.TypedValue
+import android.view.View
+import androidx.core.content.ContextCompat
+import com.tencent.trtc.TXChorusMusicPlayer
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.karaoke.store.KaraokeStore
+import io.trtc.tuikit.atomicx.karaoke.store.utils.LyricAlign
+
+val TXChorusMusicPlayer.TXLyricLine.fullContent: String
+    get() = characterArray.joinToString("") { it.utf8Character }
+
+class LyricView(
+    context: Context,
+    private val store: KaraokeStore,
+) : View(context) {
+    private var currentProgressMs: Long = 0L
+    private var currentLineIndex: Int = 0
+    private var highlightTextSizeSp: Float = 14f
+    private var nextLineTextSizeSp: Float = 10f
+    private var lineSpace: Float = spToPx(nextLineTextSizeSp) * 1.8f
+    private val colorBlue = ContextCompat.getColor(context, R.color.karaoke_lyric_blue)
+    private val colorWhite = ContextCompat.getColor(context, R.color.karaoke_white)
+    private val colorGrey = ContextCompat.getColor(context, R.color.karaoke_lyric_grey)
+    private var lyricAlign: LyricAlign = LyricAlign.RIGHT
+
+    private val paintCurrentLine = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = colorWhite
+        textAlign = Paint.Align.RIGHT
+        textSize = spToPx(highlightTextSizeSp)
+        typeface = Typeface.DEFAULT_BOLD
+    }
+    private val paintHighlightedLine = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = colorBlue
+        textAlign = Paint.Align.RIGHT
+        textSize = spToPx(highlightTextSizeSp)
+        typeface = Typeface.DEFAULT_BOLD
+    }
+    private val paintNextLine = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = colorGrey
+        textAlign = Paint.Align.RIGHT
+        textSize = spToPx(nextLineTextSizeSp)
+        typeface = Typeface.DEFAULT
+    }
+
+    init {
+        updatePaintAlign()
+        updatePaintTextSize()
+    }
+
+    fun setLyricAlign(align: LyricAlign) {
+        if (lyricAlign != align) {
+            lyricAlign = align
+            updatePaintAlign()
+            invalidate()
+        }
+    }
+
+    private fun updatePaintAlign() {
+        val align = when (lyricAlign) {
+            LyricAlign.RIGHT -> Paint.Align.RIGHT
+            LyricAlign.CENTER -> Paint.Align.CENTER
+        }
+        paintCurrentLine.textAlign = align
+        paintHighlightedLine.textAlign = align
+        paintNextLine.textAlign = align
+    }
+
+    fun setLyricTextSize(highlightSp: Float, nextLineSp: Float) {
+        highlightTextSizeSp = highlightSp
+        nextLineTextSizeSp = nextLineSp
+        updatePaintTextSize()
+        invalidate()
+    }
+
+    private fun updatePaintTextSize() {
+        paintCurrentLine.textSize = spToPx(highlightTextSizeSp)
+        paintHighlightedLine.textSize = spToPx(highlightTextSizeSp)
+        paintNextLine.textSize = spToPx(nextLineTextSizeSp)
+        lineSpace = spToPx(nextLineTextSizeSp) * 1.8f
+    }
+
+    fun spToPx(sp: Float): Float {
+        return TypedValue.applyDimension(
+            TypedValue.COMPLEX_UNIT_SP,
+            sp,
+            context.resources.displayMetrics
+        )
+    }
+
+    fun setPlayProgress(progressMs: Long) {
+        currentProgressMs = progressMs
+        store.songLyrics.value?.let { mLyricList ->
+            if (mLyricList.isEmpty()) return@let
+            val newIndex = mLyricList.indexOfLast { it.startTimeMs <= progressMs }
+            currentLineIndex = if (newIndex != -1) newIndex else 0
+        }
+        invalidate()
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        super.onDraw(canvas)
+        val mLyricList = store.songLyrics.value ?: return
+        if (currentLineIndex !in mLyricList.indices) return
+
+        val mViewWidth = width.toFloat()
+        val mViewHeight = height.toFloat()
+        val mTextX = when (lyricAlign) {
+            LyricAlign.RIGHT -> mViewWidth
+            LyricAlign.CENTER -> mViewWidth / 2
+        }
+
+        val line1Y = mViewHeight / 2
+        val line2Y = line1Y + lineSpace
+
+        val currentLineData = mLyricList[currentLineIndex]
+        val currentLineText = currentLineData.fullContent
+        val currentLineWidth = paintCurrentLine.measureText(currentLineText)
+
+        val nextLineIndex = currentLineIndex + 1
+        val nextLineText =
+            if (nextLineIndex in mLyricList.indices) mLyricList[nextLineIndex].fullContent else ""
+
+        if (currentLineWidth > mViewWidth) {
+            val fittingChars = paintCurrentLine.breakText(currentLineText, true, mViewWidth, null)
+            val line1 = currentLineText.substring(0, fittingChars)
+            val line2 = currentLineText.substring(fittingChars)
+
+            val line1Duration =
+                currentLineData.characterArray.take(fittingChars).sumOf { it.durationMs }
+            val timeInLine = (currentProgressMs - currentLineData.startTimeMs).coerceAtLeast(0)
+
+            if (timeInLine < line1Duration) {
+                val progress = if (line1Duration > 0) timeInLine.toFloat() / line1Duration else 0f
+                drawSingleLineHighlight(canvas, line1, progress, line1Y, mTextX)
+                drawTruncatedLine(canvas, line2, line2Y, mTextX, false)
+            } else {
+                val line2Duration = (currentLineData.durationMs - line1Duration).coerceAtLeast(1)
+                val timeInLine2 = timeInLine - line1Duration
+                val progress = timeInLine2.toFloat() / line2Duration
+                drawSingleLineHighlight(canvas, line2, progress, line1Y, mTextX)
+                drawTruncatedLine(canvas, nextLineText, line2Y, mTextX, true)
+            }
+        } else {
+            val progress = calcCurrentLineProgress(currentProgressMs, currentLineData)
+            drawSingleLineHighlight(canvas, currentLineText, progress, line1Y, mTextX)
+            drawTruncatedLine(canvas, nextLineText, line2Y, mTextX, true)
+        }
+    }
+
+    private fun drawSingleLineHighlight(
+        canvas: Canvas,
+        text: String,
+        progress: Float,
+        y: Float,
+        x: Float
+    ) {
+        canvas.drawText(text, x, y, paintCurrentLine)
+
+        val textWidth = paintCurrentLine.measureText(text)
+        val highlightWidth = textWidth * progress.coerceIn(0f, 1f)
+        val textLeft = when (lyricAlign) {
+            LyricAlign.RIGHT -> x - textWidth
+            LyricAlign.CENTER -> x - textWidth / 2
+        }
+        canvas.save()
+        val clipPath = Path()
+        clipPath.addRect(
+            textLeft, y - paintCurrentLine.textSize,
+            textLeft + highlightWidth, y + paintCurrentLine.descent(), Path.Direction.CW
+        )
+        canvas.clipPath(clipPath)
+
+        canvas.drawText(text, x, y, paintHighlightedLine)
+        canvas.restore()
+    }
+
+    private fun drawTruncatedLine(
+        canvas: Canvas,
+        text: String,
+        y: Float,
+        x: Float,
+        truncate: Boolean
+    ) {
+        if (text.isEmpty()) return
+
+        if (truncate && paintNextLine.measureText(text) > width && width > 0) {
+            val ellipsis = "..."
+            val ellipsisWidth = paintNextLine.measureText(ellipsis)
+            val availableWidth = width - ellipsisWidth
+            val fittingChars = paintNextLine.breakText(text, true, availableWidth, null)
+            val truncatedText = text.substring(0, fittingChars) + ellipsis
+            canvas.drawText(truncatedText, x, y, paintNextLine)
+        } else {
+            canvas.drawText(text, x, y, paintNextLine)
+        }
+    }
+
+    companion object {
+        fun calcCurrentLineProgress(
+            currentTimeMillis: Long,
+            txLyricLine: TXChorusMusicPlayer.TXLyricLine,
+        ): Float {
+            val lineDuration = txLyricLine.durationMs
+            if (lineDuration <= 0) {
+                return if (currentTimeMillis > txLyricLine.startTimeMs) 1f else 0f
+            }
+            val offsetTime = currentTimeMillis - txLyricLine.startTimeMs
+            val progress = offsetTime / lineDuration.toFloat()
+            return progress.coerceIn(0f, 1f)
+        }
+    }
+}

+ 345 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/PitchView.kt

@@ -0,0 +1,345 @@
+package io.trtc.tuikit.atomicx.karaoke.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.graphics.Typeface.BOLD
+import android.graphics.drawable.Drawable
+import android.util.TypedValue
+import android.view.View
+import androidx.core.content.ContextCompat
+import com.tencent.trtc.TXChorusMusicPlayer
+import io.trtc.tuikit.atomicx.R
+import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.random.Random
+
+class PitchView(
+    context: Context,
+) : View(context) {
+
+    private data class Butterfly(
+        val drawable: Drawable, val x0: Float, val y0: Float, val angle: Float,
+        val scale: Float, val baseRotation: Float, val startTime: Long, val lifeMs: Long
+    )
+
+    private val PITCH_TIME_TO_PIXELS_RATIO = 0.15f
+    private val PITCH_LINE_HEIGHT_DP = 3f
+    private val PITCH_DOT_RADIUS_DP = 4.5f
+    private val PITCH_HIT_TOLERANCE = 0.0f
+    private val SCORE_TEXT_SIZE_SP = 8f
+    private val SCORE_LABEL_GAP_DP = 3f
+    private val SCORE_BUBBLE_HEIGHT_DP = 12f
+    private val DOT_ANIMATION_SMOOTHING_FACTOR = 0.2f
+    private val lineColor = ContextCompat.getColor(context, R.color.karaoke_pitch_line)
+    private val highlightColor = ContextCompat.getColor(context, R.color.karaoke_text_color_red)
+    private val dotColor = ContextCompat.getColor(context, R.color.karaoke_white)
+    private val scoreLineColor = ContextCompat.getColor(context, R.color.karaoke_color_grey_8c)
+    private val scoreTextColor = ContextCompat.getColor(context, R.color.karaoke_pitch_score_text)
+    private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        style = Paint.Style.STROKE
+        strokeWidth = dpToPx(PITCH_LINE_HEIGHT_DP)
+        color = lineColor
+        strokeCap = Paint.Cap.ROUND
+    }
+    private val highlightLinePaint = Paint(linePaint).apply { color = highlightColor }
+    private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        style = Paint.Style.FILL
+        color = dotColor
+        setShadowLayer(8f, 0f, 2f, 0x77000000)
+    }
+    private val scoreLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = scoreLineColor
+        strokeWidth = dpToPx(1f)
+        style = Paint.Style.STROKE
+    }
+    private val scoreTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = scoreTextColor
+        style = Paint.Style.FILL
+        textSize = spToPx(SCORE_TEXT_SIZE_SP)
+        textAlign = Paint.Align.CENTER
+        typeface = Typeface.create(Typeface.DEFAULT, BOLD)
+    }
+    private val scoreTagDrawable: Drawable? by lazy {
+        ContextCompat.getDrawable(context, R.drawable.karaoke_score_bg)
+    }
+    private val butterflies = mutableListOf<Butterfly>()
+    private val butterflyDrawables: List<Drawable?> by lazy {
+        listOf(ContextCompat.getDrawable(context, R.drawable.karaoke_song_well_icon))
+    }
+    private val butterflyFlyDistance = dpToPx(52f)
+    private val butterflyLife = 1350L
+    private var pitchList: List<TXChorusMusicPlayer.TXReferencePitch> = emptyList()
+    private var userPitch: Int = 0
+    private var currentProgressMs: Long = 0L
+    private var currentScore: Int = -1
+    private var isScoringEnabled: Boolean = false
+    private var hitProgress: FloatArray = FloatArray(0)
+    private var pitchStartOffsetsPx: List<Float> = emptyList()
+    private var minPitch: Int = 0
+    private var maxPitch: Int = 100
+    private var scrollOffset: Float = 0f
+    private var currentDotTargetY: Float? = null
+    private var currentDotAnimatedY: Float? = null
+    private var lastHitSegmentIndexForButterfly = -1
+
+    fun setPitchList(list: List<TXChorusMusicPlayer.TXReferencePitch>?) {
+        val pitchList = list ?: emptyList()
+        this@PitchView.pitchList = pitchList
+        hitProgress = FloatArray(pitchList.size)
+
+        if (pitchList.isEmpty()) {
+            pitchStartOffsetsPx = emptyList()
+        } else {
+            minPitch = 0
+            maxPitch = 100
+            pitchStartOffsetsPx = pitchList.map { it.startTimeMs * PITCH_TIME_TO_PIXELS_RATIO }
+        }
+        resetState()
+        invalidate()
+    }
+
+    fun setPlayProgress(progressMs: Long) {
+        currentProgressMs = progressMs
+        updateStateByProgress()
+        invalidate()
+    }
+
+    fun setUserPitch(pitch: Int) {
+        val newPitch = pitch.coerceIn(0, 100)
+        if (userPitch != newPitch) {
+            userPitch = newPitch
+            invalidate()
+        }
+    }
+
+    fun setScore(score: Int) {
+        if (currentScore != score) {
+            currentScore = score
+            invalidate()
+        }
+    }
+
+    fun setScoringEnabled(enabled: Boolean) {
+        if (isScoringEnabled != enabled) {
+            isScoringEnabled = enabled
+            if (!enabled) {
+                currentScore = -1
+            }
+            invalidate()
+        }
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        super.onDraw(canvas)
+
+        if (width == 0 || height == 0) {
+            return
+        }
+
+        val viewCenterX = width / 2f
+        val viewHeight = height.toFloat()
+
+        if (pitchList.isEmpty()) {
+            canvas.drawLine(viewCenterX, 0f, viewCenterX, viewHeight, scoreLinePaint)
+            drawUserPitchDot(canvas, viewCenterX)
+            return
+        }
+
+        for (i in pitchList.indices) {
+            val pitchData = pitchList[i]
+            val lineLength = pitchData.durationMs * PITCH_TIME_TO_PIXELS_RATIO
+            val startOffsetPx = pitchStartOffsetsPx[i]
+
+            val x1 = viewCenterX + startOffsetPx - scrollOffset
+            val x2 = x1 + lineLength
+
+            if (x2 < 0 || x1 > width) {
+                continue
+            }
+
+            val y = convertPitchToY(pitchData.referencePitch.toFloat())
+
+            canvas.drawLine(x1, y, x2, y, linePaint)
+
+            val hitRatio = hitProgress[i]
+            if (hitRatio > 0) {
+                val highlightWidth = (x2 - x1) * hitRatio
+                canvas.drawLine(x1, y, x1 + highlightWidth, y, highlightLinePaint)
+            }
+        }
+
+        canvas.drawLine(viewCenterX, 0f, viewCenterX, viewHeight, scoreLinePaint)
+        drawUserPitchDot(canvas, viewCenterX)
+        drawButterflies(canvas)
+    }
+
+    private fun resetState() {
+        currentProgressMs = 0L
+        userPitch = 0
+        scrollOffset = 0f
+        currentDotTargetY = null
+        currentDotAnimatedY = null
+        butterflies.clear()
+        lastHitSegmentIndexForButterfly = -1
+        currentScore = -1
+        hitProgress.fill(0f)
+    }
+
+    private fun updateStateByProgress() {
+        scrollOffset = currentProgressMs * PITCH_TIME_TO_PIXELS_RATIO
+
+        val currentSegmentIndex = pitchList.indexOfFirst {
+            currentProgressMs >= it.startTimeMs && currentProgressMs < (it.startTimeMs + it.durationMs)
+        }
+
+        if (currentSegmentIndex != -1) {
+            checkPitchHit(currentSegmentIndex)
+        } else {
+            lastHitSegmentIndexForButterfly = -1
+        }
+
+        updateButterflies()
+    }
+
+    private fun checkPitchHit(currentSegmentIndex: Int) {
+        if (userPitch < 0) return
+
+        val segment = pitchList[currentSegmentIndex]
+        val referencePitch = segment.referencePitch
+        val pitchDifference = abs(userPitch - referencePitch)
+
+        if (pitchDifference <= PITCH_HIT_TOLERANCE) {
+            val progressInSegment = (currentProgressMs - segment.startTimeMs).toFloat()
+            val currentHitRatio = (progressInSegment / segment.durationMs).coerceIn(0f, 1f)
+            hitProgress[currentSegmentIndex] = hitProgress[currentSegmentIndex].coerceAtLeast(currentHitRatio)
+
+            if (lastHitSegmentIndexForButterfly != currentSegmentIndex) {
+                lastHitSegmentIndexForButterfly = currentSegmentIndex
+                val y = convertPitchToY(referencePitch.toFloat())
+                emitButterfly(width / 2f, y)
+            }
+        }
+    }
+
+    private fun drawUserPitchDot(canvas: Canvas, centerX: Float) {
+        val defaultY = convertPitchToY(minPitch.toFloat())
+        var animatedY = currentDotAnimatedY
+
+        val finalTargetY = if (currentScore < 0) {
+            defaultY
+        } else {
+            convertPitchToY(userPitch.toFloat())
+        }
+
+        if (animatedY == null) {
+            animatedY = finalTargetY
+        }
+
+        val diff = finalTargetY - animatedY
+        if (abs(diff) < 1f) {
+            animatedY = finalTargetY
+        } else {
+            animatedY += diff * DOT_ANIMATION_SMOOTHING_FACTOR
+            invalidate()
+        }
+
+        currentDotAnimatedY = animatedY
+        canvas.drawCircle(centerX, animatedY, dpToPx(PITCH_DOT_RADIUS_DP), dotPaint)
+
+        if (isScoringEnabled) {
+            drawScoreTag(canvas, centerX, animatedY)
+        }
+    }
+
+    private fun drawScoreTag(canvas: Canvas, centerX: Float, dotY: Float) {
+        val tagDrawable = scoreTagDrawable ?: return
+        val scoreText = if (currentScore < 0) "评分" else currentScore.toString()
+
+        val tagHeight = dpToPx(SCORE_BUBBLE_HEIGHT_DP)
+        val scale = tagHeight / tagDrawable.intrinsicHeight.toFloat()
+        val tagWidth = tagDrawable.intrinsicWidth * scale
+
+        val dotRadius = dpToPx(PITCH_DOT_RADIUS_DP)
+        val labelGap = dpToPx(SCORE_LABEL_GAP_DP)
+
+        val tagTop = (dotY - dotRadius - labelGap - tagHeight)
+        tagDrawable.setBounds(
+            (centerX - tagWidth / 2).toInt(),
+            tagTop.toInt(),
+            (centerX + tagWidth / 2).toInt(),
+            (tagTop + tagHeight).toInt()
+        )
+        tagDrawable.draw(canvas)
+
+        val textBaseY = tagTop + tagHeight / 2f + getTextHeightCenterOffset(scoreTextPaint)
+        canvas.drawText(scoreText, centerX, textBaseY, scoreTextPaint)
+    }
+
+    private fun convertPitchToY(pitch: Float): Float {
+        if (height == 0) return 0f
+
+        val drawTop = height * 0.1f
+        val drawHeight = height * 0.8f
+        val pitchRange = (maxPitch - minPitch).toFloat().coerceAtLeast(1f)
+        val clampedPitch = pitch.coerceIn(minPitch.toFloat(), maxPitch.toFloat())
+        val percent = (clampedPitch - minPitch) / pitchRange
+        return drawTop + (1.0f - percent) * drawHeight
+    }
+
+    private fun dpToPx(dp: Float): Float =
+        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
+
+    private fun spToPx(sp: Float): Float =
+        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics)
+
+    private fun getTextHeightCenterOffset(paint: Paint): Float {
+        val metrics = paint.fontMetrics
+        return (metrics.descent - metrics.ascent) / 2 - metrics.descent
+    }
+
+    private fun emitButterfly(x: Float, y: Float) {
+        val drawable = butterflyDrawables.filterNotNull().randomOrNull() ?: return
+        val angle = 240f + (Random.nextFloat() - 0.5f) * 20f
+        val scale = 1.00f + Random.nextFloat() * 0.34f
+        val baseRotation = -15f + Random.nextFloat() * 30f
+        butterflies.add(
+            Butterfly(
+                drawable = drawable, x0 = x, y0 = y, angle = angle, scale = scale,
+                baseRotation = baseRotation, startTime = System.currentTimeMillis(), lifeMs = butterflyLife
+            )
+        )
+    }
+
+    private fun updateButterflies() {
+        val now = System.currentTimeMillis()
+        butterflies.removeAll { now - it.startTime > it.lifeMs }
+    }
+
+    private fun drawButterflies(canvas: Canvas) {
+        val now = System.currentTimeMillis()
+        for (b in butterflies) {
+            val t = ((now - b.startTime).toFloat() / b.lifeMs).coerceIn(0f, 1f)
+            val rad = Math.toRadians(b.angle.toDouble())
+            val dx = butterflyFlyDistance * t * cos(rad).toFloat()
+            val dy = butterflyFlyDistance * t * sin(rad).toFloat()
+            val x = b.x0 + dx
+            val y = b.y0 + dy
+            val scale = b.scale * (1.00f - 0.14f * t)
+            val d = b.drawable
+            val w = d.intrinsicWidth * scale
+            val h = d.intrinsicHeight * scale
+            val alpha = (180 * (1 - t)).toInt().coerceIn(0, 255)
+            canvas.save()
+            canvas.translate(x, y)
+            val swing = sin(t * Math.PI * 2.0 * 1.1f).toFloat() * 18f
+            canvas.rotate(b.baseRotation + swing + b.angle)
+            d.setBounds((-w / 2).toInt(), (-h / 2).toInt(), (w / 2).toInt(), (h / 2).toInt())
+            d.alpha = alpha
+            d.draw(canvas)
+            canvas.restore()
+        }
+    }
+}

+ 196 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/SongRequestPanel.kt

@@ -0,0 +1,196 @@
+package io.trtc.tuikit.atomicx.karaoke.view
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.GONE
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Observer
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.karaoke.store.KaraokeStore
+import io.trtc.tuikit.atomicx.karaoke.store.utils.MusicInfo
+import io.trtc.tuikit.atomicx.karaoke.view.adapter.KaraokeOrderedListAdapter
+import io.trtc.tuikit.atomicx.karaoke.view.adapter.KaraokeSongListAdapter
+import io.trtc.tuikit.atomicx.widget.basicwidget.popover.AtomicPopover
+
+class SongRequestPanel(
+    context: Context,
+    private val store: KaraokeStore,
+    private val isDisplayExitView: Boolean,
+) : AtomicPopover(context) {
+    private lateinit var recyclerSongBrowserView: RecyclerView
+    private lateinit var recyclerOrderedListView: RecyclerView
+    private var orderedTabView: TextView? = null
+    private val adapterSongList = KaraokeSongListAdapter(store)
+    private val adapterOrderedList = KaraokeOrderedListAdapter(store)
+    private val songSelectedListObserver = Observer(this::songSelectedListChange)
+    private val roomDismissedObserver = Observer(this::roomDismissedChange)
+    private val songLibraryListObserver = Observer(this::songLibraryListChange)
+
+    init {
+        initView()
+    }
+
+    private fun initView() {
+        val view: View =
+            LayoutInflater.from(context).inflate(R.layout.karaoke_song_request_panel, null)
+
+        setPanelHeight(PanelHeight.Ratio(0.6F))
+        initTabLayout(view)
+        initExitView(view)
+        initSongBrowserView(view)
+        initQueueManagerView(view)
+        configDialogHeight(view)
+        setContent(view)
+    }
+
+    private fun addObserve() {
+        store.songCatalog.observeForever(songLibraryListObserver)
+        store.songQueue.observeForever(songSelectedListObserver)
+        store.isRoomDismissed.observeForever(roomDismissedObserver)
+    }
+
+    private fun removeObserve() {
+        store.songCatalog.removeObserver(songLibraryListObserver)
+        store.songQueue.removeObserver(songSelectedListObserver)
+        store.isRoomDismissed.removeObserver(roomDismissedObserver)
+    }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        addObserve()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        removeObserve()
+    }
+
+    private fun songLibraryListChange(list: List<MusicInfo>) {
+        adapterSongList.submitList(list.toList())
+    }
+
+    private fun songSelectedListChange(list: List<TUISongListManager.SongInfo>) {
+        orderedTabView?.text = context.getString(R.string.karaoke_ordered_count, list.size)
+        adapterOrderedList.submitList(list.toList())
+        store.updateSongCatalog(adapterSongList.currentList)
+        adapterSongList.submitList(adapterSongList.currentList)
+        triggerSongListRefresh()
+    }
+
+    private fun triggerSongListRefresh() {
+        val currentList = adapterSongList.currentList
+        adapterSongList.submitList(currentList.toList())
+    }
+
+    private fun roomDismissedChange(isRoomDismissed: Boolean) {
+        if (isRoomDismissed && this.isShowing) {
+            hide()
+        }
+    }
+
+    private fun initSongBrowserView(view: View) {
+        recyclerSongBrowserView = view.findViewById(R.id.rv_song_browser_list)
+        recyclerSongBrowserView.layoutManager = LinearLayoutManager(context)
+        recyclerSongBrowserView.adapter = adapterSongList
+        recyclerSongBrowserView.visibility = View.VISIBLE
+    }
+
+    private fun initQueueManagerView(view: View) {
+        recyclerOrderedListView = view.findViewById(R.id.rv_ordered_list)
+        recyclerOrderedListView.layoutManager = LinearLayoutManager(context)
+        recyclerOrderedListView.adapter = adapterOrderedList
+        recyclerOrderedListView.visibility = GONE
+    }
+
+    private fun initExitView(view: View) {
+        val exitView: FrameLayout = view.findViewById(R.id.fl_exit_request)
+        if (store.isRoomOwner.value == false || !isDisplayExitView) {
+            exitView.visibility = GONE
+        }
+        exitView.setOnClickListener {
+            super.hide()
+            store.enableRequestMusic(false)
+        }
+    }
+
+    override fun show() {
+        super.show()
+        if (store.isDisplayFloatView.value == false) {
+            store.enableRequestMusic(true)
+        }
+    }
+
+    private fun initTabLayout(view: View) {
+        val tabLayout = view.findViewById<TabLayout>(R.id.tab)
+        tabLayout.removeAllTabs()
+        val tabTitles = listOf(
+            R.string.karaoke_order_song,
+            R.string.karaoke_ordered_count
+        )
+        val tabColors = listOf(
+            R.color.karaoke_color_white,
+            R.color.karaoke_text_color_grey_4d
+        )
+
+        tabTitles.forEachIndexed { index, titleRes ->
+            tabLayout.addTab(createTab(view, titleRes, tabColors[index], index), index == 0)
+        }
+
+        tabLayout.addOnTabSelectedListener(object : OnTabSelectedListener {
+            override fun onTabSelected(tab: TabLayout.Tab) {
+                setTabTextColor(tab, R.color.karaoke_color_white)
+                when (tab.position) {
+                    0 -> {
+                        recyclerSongBrowserView.visibility = View.VISIBLE
+                        recyclerOrderedListView.visibility = GONE
+                    }
+
+                    1 -> {
+                        recyclerSongBrowserView.visibility = GONE
+                        recyclerOrderedListView.visibility = View.VISIBLE
+                    }
+                }
+            }
+
+            override fun onTabUnselected(tab: TabLayout.Tab) {
+                setTabTextColor(tab, R.color.karaoke_text_color_grey_4d)
+
+            }
+
+            override fun onTabReselected(tab: TabLayout.Tab) {}
+        })
+    }
+
+    private fun createTab(view: View, titleRes: Int, textColorRes: Int, index: Int): TabLayout.Tab {
+        val context = view.context
+        val tabView =
+            LayoutInflater.from(context).inflate(R.layout.karaoke_tab_item, null) as TextView
+        tabView.text = context.getString(titleRes)
+        tabView.setTextColor(ContextCompat.getColor(context, textColorRes))
+        if (index == 1) {
+            orderedTabView = tabView
+        }
+        return (view.findViewById<TabLayout>(R.id.tab)).newTab().setCustomView(tabView)
+    }
+
+    private fun setTabTextColor(tab: TabLayout.Tab, colorRes: Int) {
+        val tabView = tab.customView as? TextView ?: return
+        tabView.setTextColor(ContextCompat.getColor(tabView.context, colorRes))
+    }
+
+    private fun configDialogHeight(view: View) {
+        view.layoutParams = ViewGroup.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            (context.resources.displayMetrics.heightPixels * 0.6).toInt()
+        )
+    }
+}

+ 165 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/adapter/KaraokeOrderedListAdapter.kt

@@ -0,0 +1,165 @@
+package io.trtc.tuikit.atomicx.karaoke.view.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager
+import io.trtc.tuikit.atomicx.karaoke.store.KaraokeStore
+import io.trtc.tuikit.atomicx.karaoke.store.utils.PlaybackState
+import io.trtc.tuikit.atomicx.karaoke.view.adapter.KaraokeOrderedListAdapter.SongViewHolder
+import com.trtc.tuikit.common.imageloader.ImageLoader
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+
+class KaraokeOrderedListAdapter(private val store: KaraokeStore) :
+    ListAdapter<TUISongListManager.SongInfo, SongViewHolder>(DIFF) {
+    companion object {
+        val DIFF = object : DiffUtil.ItemCallback<TUISongListManager.SongInfo>() {
+            override fun areItemsTheSame(
+                oldItem: TUISongListManager.SongInfo,
+                newItem: TUISongListManager.SongInfo,
+            ): Boolean {
+                return oldItem == newItem
+            }
+
+            override fun areContentsTheSame(
+                oldItem: TUISongListManager.SongInfo,
+                newItem: TUISongListManager.SongInfo,
+            ): Boolean {
+                return false
+            }
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder {
+        val view = LayoutInflater.from(parent.context)
+            .inflate(R.layout.karaoke_music_requested_item, parent, false)
+        return SongViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
+        holder.bind(getItem(position), position)
+    }
+
+    inner class SongViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        private val imagePlaying: ImageView = itemView.findViewById(R.id.iv_playing)
+        private val textOrderIndex: TextView = itemView.findViewById(R.id.tv_order_index)
+        private val imageCover: ImageView = itemView.findViewById(R.id.iv_cover)
+        private val textSongName: TextView = itemView.findViewById(R.id.tv_song_name)
+        private val imageRequesterAvatar: AtomicAvatar =
+            itemView.findViewById(R.id.iv_user_avatar)
+        private val textRequesterName: TextView = itemView.findViewById(R.id.tv_requester_name)
+        private val imagePause: ImageView = itemView.findViewById(R.id.iv_pause)
+        private val imageNext: ImageView = itemView.findViewById(R.id.iv_next)
+        private val imagePin: ImageView = itemView.findViewById(R.id.iv_pin)
+        private val imageDelete: ImageView = itemView.findViewById(R.id.iv_delete)
+
+        fun bind(song: TUISongListManager.SongInfo, position: Int) {
+            textSongName.text = song.songName
+            initMusicPositionView(position)
+            initPlayingPauseView(position)
+            initPlayingNextView(position)
+            initMusicDeleteView(song, position)
+            initMusicPinView(song, position)
+            initOrderName(song)
+            initAvatarView(song)
+            initMusicCover(song)
+            initFunctionVisible()
+        }
+
+        private fun initOrderName(song: TUISongListManager.SongInfo) {
+            if (song.songName.isEmpty()) {
+                textRequesterName.text = song.requester.userId
+            } else {
+                textRequesterName.text = song.requester.userName
+            }
+        }
+
+        private fun initAvatarView(song: TUISongListManager.SongInfo) {
+            imageRequesterAvatar.setContent(
+                AtomicAvatar.AvatarContent.URL(
+                    song.requester.avatarUrl,
+                    R.drawable.karaoke_song_cover
+                )
+            )
+        }
+
+        private fun initMusicCover(music: TUISongListManager.SongInfo) {
+            ImageLoader.load(
+                imageCover.context,
+                imageCover,
+                music.coverUrl,
+                R.drawable.karaoke_song_cover
+            )
+        }
+
+        private fun initMusicPositionView(position: Int) {
+            imagePlaying.visibility = if (position == 0) VISIBLE else GONE
+            textOrderIndex.visibility = if (position == 0) GONE else VISIBLE
+            textOrderIndex.text = (position + 1).toString()
+        }
+
+        private fun initPlayingPauseView(position: Int) {
+            if (position == 0) {
+                imagePause.visibility = VISIBLE
+                val isPlaying = (store.playbackState.value != PlaybackState.STOP &&
+                        store.playbackState.value != PlaybackState.PAUSE)
+                imagePause.setImageResource(
+                    if (isPlaying) R.drawable.karaoke_music_resume else R.drawable.karaoke_music_pause
+                )
+                imagePause.setOnClickListener {
+                    val newIsPlaying =  (store.playbackState.value != PlaybackState.STOP &&
+                            store.playbackState.value != PlaybackState.PAUSE)
+                    if (newIsPlaying) {
+                        store.pausePlayback()
+                        imagePause.setImageResource(R.drawable.karaoke_music_pause)
+                    } else {
+                        store.resumePlayback()
+                        imagePause.setImageResource(R.drawable.karaoke_music_resume)
+                    }
+                }
+            } else {
+                imagePause.visibility = GONE
+                imagePause.setOnClickListener(null)
+            }
+        }
+
+        private fun initPlayingNextView(position: Int) {
+            imageNext.visibility = if (0 == position) VISIBLE else GONE
+            imageNext.setOnClickListener {
+                store.playNextSong()
+                store.setIsDisplayScoreView(false)
+            }
+        }
+
+        private fun initMusicDeleteView(music: TUISongListManager.SongInfo, position: Int) {
+            imageDelete.visibility = if (0 != position) VISIBLE else GONE
+            imageDelete.setOnClickListener {
+                store.removeSong(music)
+            }
+        }
+
+        private fun initMusicPinView(song: TUISongListManager.SongInfo, position: Int) {
+            imagePin.visibility = if (position > 1) VISIBLE else GONE
+            imagePin.setOnClickListener {
+                store.setNextSong(song.songId)
+            }
+        }
+
+        private fun initFunctionVisible() {
+            if (store.isRoomOwner.value == false) {
+                imagePause.visibility = GONE
+                imageNext.visibility = GONE
+                imagePin.visibility = GONE
+                imageDelete.visibility = GONE
+            }
+        }
+    }
+}

+ 107 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/karaoke/view/adapter/KaraokeSongListAdapter.kt

@@ -0,0 +1,107 @@
+package io.trtc.tuikit.atomicx.karaoke.view.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.GONE
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.tencent.cloud.tuikit.engine.extension.TUISongListManager
+import com.tencent.cloud.tuikit.engine.room.TUIRoomDefine
+import com.tencent.cloud.tuikit.engine.room.TUIRoomEngine
+import com.trtc.tuikit.common.imageloader.ImageLoader
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.karaoke.store.KaraokeStore
+import io.trtc.tuikit.atomicx.karaoke.store.utils.MusicInfo
+
+class KaraokeSongListAdapter(private val store: KaraokeStore) :
+    ListAdapter<MusicInfo, KaraokeSongListAdapter.SongViewHolder>(DIFF) {
+    companion object {
+        val DIFF = object : DiffUtil.ItemCallback<MusicInfo>() {
+            override fun areItemsTheSame(oldItem: MusicInfo, newItem: MusicInfo): Boolean {
+                return oldItem == newItem
+            }
+
+            override fun areContentsTheSame(oldItem: MusicInfo, newItem: MusicInfo): Boolean {
+                return false
+            }
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder {
+        val view =
+            LayoutInflater.from(parent.context).inflate(R.layout.karaoke_music_library_item, parent, false)
+        return SongViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
+        holder.bind(getItem(position))
+    }
+
+    inner class SongViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        private val imageCover: ImageView = itemView.findViewById(R.id.iv_cover)
+        private val textSongName: TextView = itemView.findViewById(R.id.tv_song_name)
+        private val textSinger: TextView = itemView.findViewById(R.id.tv_singer)
+        private val buttonRequestSong: Button = itemView.findViewById(R.id.btn_request_music)
+
+        fun bind(music: MusicInfo) {
+            textSongName.text = music.musicName
+            textSinger.text = music.artist
+            initRequestSongButton(music)
+            initMusicCover(music)
+            initFunctionVisible()
+        }
+
+        private fun initMusicCover(music: MusicInfo) {
+            ImageLoader.load(
+                imageCover.context,
+                imageCover,
+                music.coverUrl,
+                R.drawable.karaoke_song_cover
+            )
+        }
+
+        private fun initRequestSongButton(music: MusicInfo) {
+            val isOrdered = store.songQueue.value?.any { it.songId == music.musicId } == true
+            if (isOrdered) {
+                buttonRequestSong.apply {
+                    background =
+                        ContextCompat.getDrawable(context, R.drawable.karaoke_btn_grey_edge_bg)
+                    text = context.getString(R.string.karaoke_ordered)
+                    isEnabled = false
+                    setOnClickListener(null)
+                }
+            } else {
+                buttonRequestSong.apply {
+                    background = ContextCompat.getDrawable(context, R.drawable.karaoke_btn_blue_bg)
+                    text = context.getString(R.string.karaoke_order_song)
+                    isEnabled = true
+                    setOnClickListener {
+                        val selfInfo: TUIRoomDefine.LoginUserInfo = TUIRoomEngine.getSelfInfo()
+                        val songInfo : TUISongListManager.SongInfo = TUISongListManager.SongInfo()
+                        songInfo.songId = music.musicId
+                        songInfo.songName = music.musicName
+                        songInfo.artistName = music.artist
+                        songInfo.duration = music.duration
+                        songInfo.coverUrl = music.coverUrl
+                        songInfo.requester.userId = selfInfo.userId
+                        songInfo.requester.userName = selfInfo.userName
+                        songInfo.requester.avatarUrl = selfInfo.avatarUrl
+                        store.addSong(songInfo)
+                    }
+                }
+            }
+        }
+
+        private fun initFunctionVisible() {
+            if (store.isRoomOwner.value == false) {
+                buttonRequestSong.visibility = GONE
+            }
+        }
+    }
+}

+ 24 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/pictureinpicture/PictureInPictureStore.kt

@@ -0,0 +1,24 @@
+package io.trtc.tuikit.atomicx.pictureinpicture
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+
+data class PictureInPictureState(
+    val isPictureInPictureMode: StateFlow<Boolean>,
+)
+
+class PictureInPictureStore private constructor() {
+
+    companion object {
+        val shared: PictureInPictureStore by lazy { PictureInPictureStore() }
+    }
+
+    private val _isPictureInPictureMode = MutableStateFlow(false)
+
+    val state = PictureInPictureState(isPictureInPictureMode = _isPictureInPictureMode)
+
+    fun updateIsPictureInPictureMode(isPictureInPictureMode: Boolean) {
+        _isPictureInPictureMode.update { isPictureInPictureMode }
+    }
+}

+ 145 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/ThemeStore.kt

@@ -0,0 +1,145 @@
+package io.trtc.tuikit.atomicx.theme
+
+import android.content.Context
+import io.trtc.tuikit.atomicx.theme.tokens.ColorTokens
+import io.trtc.tuikit.atomicx.theme.tokens.DesignTokenSet
+import io.trtc.tuikit.atomicx.theme.utils.ColorAlgorithm
+import io.trtc.tuikit.atomicx.theme.utils.ThemePersistUtil
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+data class Theme(
+    val id: String,
+    val displayName: String,
+    val tokens: DesignTokenSet
+) {
+    companion object {
+
+        fun lightTheme(context: Context): Theme {
+            return Theme(
+                id = "light",
+                displayName = "Light",
+                tokens = DesignTokenSet.defaultLight(context)
+            )
+        }
+
+        fun darkTheme(context: Context): Theme {
+            return Theme(
+                id = "dark",
+                displayName = "Dark",
+                tokens = DesignTokenSet.defaultDark(context)
+            )
+        }
+    }
+}
+
+data class ThemeState(
+    val currentTheme: Theme
+)
+
+class ThemeStore private constructor(context: Context) {
+    private val appContext: Context = context.applicationContext
+    
+    private val _themeState = MutableStateFlow(ThemeState(Theme.darkTheme(appContext)))
+    val themeState: StateFlow<ThemeState> = _themeState.asStateFlow()
+
+    private var themePersistUtil: ThemePersistUtil = ThemePersistUtil(appContext)
+
+    init {
+        loadPersistedTheme()
+    }
+
+    companion object {
+        @Volatile
+        private var instance: ThemeStore? = null
+
+        fun shared(context: Context): ThemeStore {
+            return instance ?: synchronized(this) {
+                instance ?: ThemeStore(context.applicationContext).also {
+                    instance = it
+                }
+            }
+        }
+    }
+
+    fun setTheme(theme: Theme) {
+        updateThemeState(theme)
+        persistTheme(theme)
+    }
+
+    fun setPrimaryColor(hexColor: String) {
+        if (!hexColor.matches(Regex("^#[0-9A-Fa-f]{6}$"))) {
+            return
+        }
+
+        val palette = ColorAlgorithm.generateColorPalette(
+            appContext,
+            hexColor,
+            if (_themeState.value.currentTheme.id == Theme.darkTheme(appContext).id) "dark" else "light"
+        )
+
+        val newTokens = if (_themeState.value.currentTheme.id == Theme.darkTheme(appContext).id) {
+            ColorTokens.generateDarkTokens(appContext, palette)
+        } else {
+            ColorTokens.generateLightTokens(appContext, palette)
+        }
+
+        val newTheme = _themeState.value.currentTheme.copy(
+            tokens = _themeState.value.currentTheme.tokens.copy(color = newTokens)
+        )
+
+        updateThemeState(newTheme)
+        themePersistUtil.setCustomPrimaryColor(hexColor)
+    }
+
+    private fun updateThemeState(theme: Theme) {
+        _themeState.value = ThemeState(currentTheme = theme)
+    }
+
+    private fun loadPersistedTheme() {
+        if (themePersistUtil.getUserHasManuallySetTheme()) {
+            themePersistUtil.getCurrentThemeId()?.let { themeId ->
+                loadTheme(themeId)
+                return
+            }
+        }
+
+        if (themePersistUtil.getFollowSystemAppearance()) {
+            loadThemeBasedOnSystemAppearance()
+        }
+    }
+
+    private fun persistTheme(theme: Theme) {
+        themePersistUtil.setCurrentThemeId(theme.id)
+        themePersistUtil.setUserHasManuallySetTheme(true)
+        themePersistUtil.setFollowSystemAppearance(false)
+    }
+
+    private fun loadTheme(themeId: String) {
+        val theme = when (themeId) {
+            "light" -> Theme.lightTheme(appContext)
+            "dark" -> Theme.darkTheme(appContext)
+            else -> Theme.lightTheme(appContext)
+        }
+
+        themePersistUtil.getCustomPrimaryColor()?.let { hexColor ->
+            val palette = ColorAlgorithm.generateColorPalette(appContext, hexColor, themeId)
+            val customTokens = if (themeId == "dark") {
+                ColorTokens.generateDarkTokens(appContext, palette)
+            } else {
+                ColorTokens.generateLightTokens(appContext, palette)
+            }
+            val customTheme = theme.copy(
+                tokens = theme.tokens.copy(color = customTokens)
+            )
+            updateThemeState(customTheme)
+        } ?: run {
+            updateThemeState(theme)
+        }
+    }
+
+    private fun loadThemeBasedOnSystemAppearance() {
+
+    }
+}

+ 678 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/tokens/ColorTokens.kt

@@ -0,0 +1,678 @@
+package io.trtc.tuikit.atomicx.theme.tokens
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat
+import io.trtc.tuikit.atomicx.R
+
+data class ColorTokens(
+    // text & icon
+    @ColorInt val textColorPrimary: Int,
+    @ColorInt val textColorSecondary: Int,
+    @ColorInt val textColorTertiary: Int,
+    @ColorInt val textColorDisable: Int,
+    @ColorInt val textColorButton: Int,
+    @ColorInt val textColorButtonDisabled: Int,
+    @ColorInt val textColorLink: Int,
+    @ColorInt val textColorLinkHover: Int,
+    @ColorInt val textColorLinkActive: Int,
+    @ColorInt val textColorLinkDisabled: Int,
+    @ColorInt val textColorAntiPrimary: Int,
+    @ColorInt val textColorAntiSecondary: Int,
+    @ColorInt val textColorWarning: Int,
+    @ColorInt val textColorSuccess: Int,
+    @ColorInt val textColorError: Int,
+    // background
+    @ColorInt val bgColorTopBar: Int,
+    @ColorInt val bgColorOperate: Int,
+    @ColorInt val bgColorDialog: Int,
+    @ColorInt val bgColorDialogModule: Int,
+    @ColorInt val bgColorEntryCard: Int,
+    @ColorInt val bgColorFunction: Int,
+    @ColorInt val bgColorBottomBar: Int,
+    @ColorInt val bgColorInput: Int,
+    @ColorInt val bgColorBubbleReciprocal: Int,
+    @ColorInt val bgColorBubbleOwn: Int,
+    @ColorInt val bgColorDefault: Int,
+    @ColorInt val bgColorTagMask: Int,
+    @ColorInt val bgColorElementMask: Int,
+    @ColorInt val bgColorMask: Int,
+    @ColorInt val bgColorMaskDisappeared: Int,
+    @ColorInt val bgColorMaskBegin: Int,
+    @ColorInt val bgColorAvatar: Int,
+    // border
+    @ColorInt val strokeColorPrimary: Int,
+    @ColorInt val strokeColorSecondary: Int,
+    @ColorInt val strokeColorModule: Int,
+    // shadow
+    @ColorInt val shadowColor: Int,
+    // status
+    @ColorInt val listColorDefault: Int,
+    @ColorInt val listColorHover: Int,
+    @ColorInt val listColorFocused: Int,
+    // button
+    @ColorInt val buttonColorPrimaryDefault: Int,
+    @ColorInt val buttonColorPrimaryHover: Int,
+    @ColorInt val buttonColorPrimaryActive: Int,
+    @ColorInt val buttonColorPrimaryDisabled: Int,
+    @ColorInt val buttonColorSecondaryDefault: Int,
+    @ColorInt val buttonColorSecondaryHover: Int,
+    @ColorInt val buttonColorSecondaryActive: Int,
+    @ColorInt val buttonColorSecondaryDisabled: Int,
+    @ColorInt val buttonColorAccept: Int,
+    @ColorInt val buttonColorHangupDefault: Int,
+    @ColorInt val buttonColorHangupDisabled: Int,
+    @ColorInt val buttonColorHangupHover: Int,
+    @ColorInt val buttonColorHangupActive: Int,
+    @ColorInt val buttonColorOn: Int,
+    @ColorInt val buttonColorOff: Int,
+    // dropdown
+    @ColorInt val dropdownColorDefault: Int,
+    @ColorInt val dropdownColorHover: Int,
+    @ColorInt val dropdownColorActive: Int,
+    // scrollbar
+    @ColorInt val scrollbarColorDefault: Int,
+    @ColorInt val scrollbarColorHover: Int,
+    // floating
+    @ColorInt val floatingColorDefault: Int,
+    @ColorInt val floatingColorOperate: Int,
+    // checkbox
+    @ColorInt val checkboxColorSelected: Int,
+    // toast
+    @ColorInt val toastColorWarning: Int,
+    @ColorInt val toastColorSuccess: Int,
+    @ColorInt val toastColorError: Int,
+    @ColorInt val toastColorDefault: Int,
+    // tag
+    @ColorInt val tagColorLevel1: Int,
+    @ColorInt val tagColorLevel2: Int,
+    @ColorInt val tagColorLevel3: Int,
+    @ColorInt val tagColorLevel4: Int,
+    // switch
+    @ColorInt val switchColorOff: Int,
+    @ColorInt val switchColorOn: Int,
+    @ColorInt val switchColorButton: Int,
+    // slider
+    @ColorInt val sliderColorFilled: Int,
+    @ColorInt val sliderColorEmpty: Int,
+    @ColorInt val sliderColorButton: Int,
+    // tab
+    @ColorInt val tabColorSelected: Int,
+    @ColorInt val tabColorUnselected: Int,
+    @ColorInt val tabColorOption: Int,
+) {
+    operator fun get(key: String): Int {
+        return when (key) {
+            // --- Text & Icon ---
+            "textColorPrimary" -> textColorPrimary
+            "textColorSecondary" -> textColorSecondary
+            "textColorTertiary" -> textColorTertiary
+            "textColorDisable" -> textColorDisable
+            "textColorButton" -> textColorButton
+            "textColorButtonDisabled" -> textColorButtonDisabled
+            "textColorLink" -> textColorLink
+            "textColorLinkHover" -> textColorLinkHover
+            "textColorLinkActive" -> textColorLinkActive
+            "textColorLinkDisabled" -> textColorLinkDisabled
+            "textColorAntiPrimary" -> textColorAntiPrimary
+            "textColorAntiSecondary" -> textColorAntiSecondary
+            "textColorWarning" -> textColorWarning
+            "textColorSuccess" -> textColorSuccess
+            "textColorError" -> textColorError
+
+            // --- Background ---
+            "bgColorTopBar" -> bgColorTopBar
+            "bgColorOperate" -> bgColorOperate
+            "bgColorDialog" -> bgColorDialog
+            "bgColorDialogModule" -> bgColorDialogModule
+            "bgColorEntryCard" -> bgColorEntryCard
+            "bgColorFunction" -> bgColorFunction
+            "bgColorBottomBar" -> bgColorBottomBar
+            "bgColorInput" -> bgColorInput
+            "bgColorBubbleReciprocal" -> bgColorBubbleReciprocal
+            "bgColorBubbleOwn" -> bgColorBubbleOwn
+            "bgColorDefault" -> bgColorDefault
+            "bgColorTagMask" -> bgColorTagMask
+            "bgColorElementMask" -> bgColorElementMask
+            "bgColorMask" -> bgColorMask
+            "bgColorMaskDisappeared" -> bgColorMaskDisappeared
+            "bgColorMaskBegin" -> bgColorMaskBegin
+            "bgColorAvatar" -> bgColorAvatar
+
+            // --- Border ---
+            "strokeColorPrimary" -> strokeColorPrimary
+            "strokeColorSecondary" -> strokeColorSecondary
+            "strokeColorModule" -> strokeColorModule
+
+            // --- Shadow ---
+            "shadowColor" -> shadowColor
+
+            // --- Status ---
+            "listColorDefault" -> listColorDefault
+            "listColorHover" -> listColorHover
+            "listColorFocused" -> listColorFocused
+
+            // --- Button ---
+            "buttonColorPrimaryDefault" -> buttonColorPrimaryDefault
+            "buttonColorPrimaryHover" -> buttonColorPrimaryHover
+            "buttonColorPrimaryActive" -> buttonColorPrimaryActive
+            "buttonColorPrimaryDisabled" -> buttonColorPrimaryDisabled
+            "buttonColorSecondaryDefault" -> buttonColorSecondaryDefault
+            "buttonColorSecondaryHover" -> buttonColorSecondaryHover
+            "buttonColorSecondaryActive" -> buttonColorSecondaryActive
+            "buttonColorSecondaryDisabled" -> buttonColorSecondaryDisabled
+            "buttonColorAccept" -> buttonColorAccept
+            "buttonColorHangupDefault" -> buttonColorHangupDefault
+            "buttonColorHangupDisabled" -> buttonColorHangupDisabled
+            "buttonColorHangupHover" -> buttonColorHangupHover
+            "buttonColorHangupActive" -> buttonColorHangupActive
+            "buttonColorOn" -> buttonColorOn
+            "buttonColorOff" -> buttonColorOff
+
+            // --- Dropdown ---
+            "dropdownColorDefault" -> dropdownColorDefault
+            "dropdownColorHover" -> dropdownColorHover
+            "dropdownColorActive" -> dropdownColorActive
+
+            // --- Scrollbar ---
+            "scrollbarColorDefault" -> scrollbarColorDefault
+            "scrollbarColorHover" -> scrollbarColorHover
+
+            // --- Floating ---
+            "floatingColorDefault" -> floatingColorDefault
+            "floatingColorOperate" -> floatingColorOperate
+
+            // --- Checkbox ---
+            "checkboxColorSelected" -> checkboxColorSelected
+
+            // --- Toast ---
+            "toastColorWarning" -> toastColorWarning
+            "toastColorSuccess" -> toastColorSuccess
+            "toastColorError" -> toastColorError
+            "toastColorDefault" -> toastColorDefault
+
+            // --- Tag ---
+            "tagColorLevel1" -> tagColorLevel1
+            "tagColorLevel2" -> tagColorLevel2
+            "tagColorLevel3" -> tagColorLevel3
+            "tagColorLevel4" -> tagColorLevel4
+
+            // --- Switch ---
+            "switchColorOff" -> switchColorOff
+            "switchColorOn" -> switchColorOn
+            "switchColorButton" -> switchColorButton
+
+            // --- Slider ---
+            "sliderColorFilled" -> sliderColorFilled
+            "sliderColorEmpty" -> sliderColorEmpty
+            "sliderColorButton" -> sliderColorButton
+
+            // --- Tab ---
+            "tabColorSelected" -> tabColorSelected
+            "tabColorUnselected" -> tabColorUnselected
+            "tabColorOption" -> tabColorOption
+
+            else -> android.graphics.Color.TRANSPARENT
+        }
+    }
+
+    companion object {
+
+        fun defaultLight(context: Context): ColorTokens {
+            return ColorTokens(
+                // text & icon
+                textColorPrimary = ContextCompat.getColor(context, R.color.black_2),
+                textColorSecondary = ContextCompat.getColor(context, R.color.black_4),
+                textColorTertiary = ContextCompat.getColor(context, R.color.black_5),
+                textColorDisable = ContextCompat.getColor(context, R.color.black_6),
+                textColorButton = ContextCompat.getColor(context, R.color.white_1),
+                textColorButtonDisabled = ContextCompat.getColor(context, R.color.white_1),
+                textColorLink = ContextCompat.getColor(context, R.color.theme_light_6),
+                textColorLinkHover = ContextCompat.getColor(context, R.color.theme_light_5),
+                textColorLinkActive = ContextCompat.getColor(context, R.color.theme_light_7),
+                textColorLinkDisabled = ContextCompat.getColor(context, R.color.theme_light_2),
+                textColorAntiPrimary = ContextCompat.getColor(context, R.color.black_2),
+                textColorAntiSecondary = ContextCompat.getColor(context, R.color.black_4),
+                textColorWarning = ContextCompat.getColor(context, R.color.orange_light_6),
+                textColorSuccess = ContextCompat.getColor(context, R.color.green_light_6),
+                textColorError = ContextCompat.getColor(context, R.color.red_light_6),
+                // background
+                bgColorTopBar = ContextCompat.getColor(context, R.color.gray_light_1),
+                bgColorOperate = ContextCompat.getColor(context, R.color.white_1),
+                bgColorDialog = ContextCompat.getColor(context, R.color.white_1),
+                bgColorDialogModule = ContextCompat.getColor(context, R.color.gray_light_2),
+                bgColorEntryCard = ContextCompat.getColor(context, R.color.gray_light_2),
+                bgColorFunction = ContextCompat.getColor(context, R.color.gray_light_2),
+                bgColorBottomBar = ContextCompat.getColor(context, R.color.white_1),
+                bgColorInput = ContextCompat.getColor(context, R.color.gray_light_2),
+                bgColorBubbleReciprocal = ContextCompat.getColor(context, R.color.gray_light_2),
+                bgColorBubbleOwn = ContextCompat.getColor(context, R.color.theme_light_2),
+                bgColorDefault = ContextCompat.getColor(context, R.color.gray_light_2),
+                bgColorTagMask = ContextCompat.getColor(context, R.color.white_4),
+                bgColorElementMask = ContextCompat.getColor(context, R.color.black_6),
+                bgColorMask = ContextCompat.getColor(context, R.color.black_4),
+                bgColorMaskDisappeared = ContextCompat.getColor(context, R.color.white_7),
+                bgColorMaskBegin = ContextCompat.getColor(context, R.color.white_1),
+                bgColorAvatar = ContextCompat.getColor(context, R.color.theme_light_2),
+                // border
+                strokeColorPrimary = ContextCompat.getColor(context, R.color.gray_light_3),
+                strokeColorSecondary = ContextCompat.getColor(context, R.color.gray_light_2),
+                strokeColorModule = ContextCompat.getColor(context, R.color.gray_light_3),
+                // shadow
+                shadowColor = ContextCompat.getColor(context, R.color.black_8),
+                // status
+                listColorDefault = ContextCompat.getColor(context, R.color.white_1),
+                listColorHover = ContextCompat.getColor(context, R.color.gray_light_1),
+                listColorFocused = ContextCompat.getColor(context, R.color.theme_light_1),
+                // button
+                buttonColorPrimaryDefault = ContextCompat.getColor(context, R.color.theme_light_6),
+                buttonColorPrimaryHover = ContextCompat.getColor(context, R.color.theme_light_5),
+                buttonColorPrimaryActive = ContextCompat.getColor(context, R.color.theme_light_7),
+                buttonColorPrimaryDisabled = ContextCompat.getColor(context, R.color.theme_light_2),
+                buttonColorSecondaryDefault = ContextCompat.getColor(context, R.color.gray_light_2),
+                buttonColorSecondaryHover = ContextCompat.getColor(context, R.color.gray_light_1),
+                buttonColorSecondaryActive = ContextCompat.getColor(context, R.color.gray_light_3),
+                buttonColorSecondaryDisabled = ContextCompat.getColor(
+                    context,
+                    R.color.gray_light_1
+                ),
+                buttonColorAccept = ContextCompat.getColor(context, R.color.green_light_6),
+                buttonColorHangupDefault = ContextCompat.getColor(context, R.color.red_light_6),
+                buttonColorHangupDisabled = ContextCompat.getColor(context, R.color.red_light_2),
+                buttonColorHangupHover = ContextCompat.getColor(context, R.color.red_light_5),
+                buttonColorHangupActive = ContextCompat.getColor(context, R.color.red_light_7),
+                buttonColorOn = ContextCompat.getColor(context, R.color.white_1),
+                buttonColorOff = ContextCompat.getColor(context, R.color.black_5),
+                // dropdown
+                dropdownColorDefault = ContextCompat.getColor(context, R.color.white_1),
+                dropdownColorHover = ContextCompat.getColor(context, R.color.gray_light_1),
+                dropdownColorActive = ContextCompat.getColor(context, R.color.theme_light_1),
+                // scrollbar
+                scrollbarColorDefault = ContextCompat.getColor(context, R.color.black_7),
+                scrollbarColorHover = ContextCompat.getColor(context, R.color.black_6),
+                // floating
+                floatingColorDefault = ContextCompat.getColor(context, R.color.white_1),
+                floatingColorOperate = ContextCompat.getColor(context, R.color.gray_light_2),
+                // checkbox
+                checkboxColorSelected = ContextCompat.getColor(context, R.color.theme_light_6),
+                // toast
+                toastColorWarning = ContextCompat.getColor(context, R.color.orange_light_1),
+                toastColorSuccess = ContextCompat.getColor(context, R.color.green_light_1),
+                toastColorError = ContextCompat.getColor(context, R.color.red_light_1),
+                toastColorDefault = ContextCompat.getColor(context, R.color.theme_light_1),
+                // tag
+                tagColorLevel1 = ContextCompat.getColor(context, R.color.accent_turquoise_light),
+                tagColorLevel2 = ContextCompat.getColor(context, R.color.theme_light_5),
+                tagColorLevel3 = ContextCompat.getColor(context, R.color.accent_purple_light),
+                tagColorLevel4 = ContextCompat.getColor(context, R.color.accent_magenta_light),
+                // switch
+                switchColorOff = ContextCompat.getColor(context, R.color.gray_light_4),
+                switchColorOn = ContextCompat.getColor(context, R.color.theme_light_6),
+                switchColorButton = ContextCompat.getColor(context, R.color.white_1),
+                // slider
+                sliderColorFilled = ContextCompat.getColor(context, R.color.theme_light_6),
+                sliderColorEmpty = ContextCompat.getColor(context, R.color.gray_light_3),
+                sliderColorButton = ContextCompat.getColor(context, R.color.white_1),
+                // tab
+                tabColorSelected = ContextCompat.getColor(context, R.color.theme_light_2),
+                tabColorUnselected = ContextCompat.getColor(context, R.color.gray_light_2),
+                tabColorOption = ContextCompat.getColor(context, R.color.gray_light_3)
+            )
+        }
+
+        fun defaultDark(context: Context): ColorTokens {
+            return ColorTokens(
+                // text & icon
+                textColorPrimary = ContextCompat.getColor(context, R.color.text_color_primary),
+                textColorSecondary = ContextCompat.getColor(context, R.color.text_color_secondary),
+                textColorTertiary = ContextCompat.getColor(context, R.color.text_color_tertiary),
+                textColorDisable = ContextCompat.getColor(context, R.color.text_color_disable),
+                textColorButton = ContextCompat.getColor(context, R.color.text_color_button),
+                textColorButtonDisabled = ContextCompat.getColor(
+                    context,
+                    R.color.text_color_button_disabled
+                ),
+                textColorLink = ContextCompat.getColor(context, R.color.text_color_link),
+                textColorLinkHover = ContextCompat.getColor(context, R.color.text_color_link_hover),
+                textColorLinkActive = ContextCompat.getColor(
+                    context,
+                    R.color.text_color_link_active
+                ),
+                textColorLinkDisabled = ContextCompat.getColor(
+                    context,
+                    R.color.text_color_link_disabled
+                ),
+                textColorAntiPrimary = ContextCompat.getColor(
+                    context,
+                    R.color.text_color_anti_primary
+                ),
+                textColorAntiSecondary = ContextCompat.getColor(
+                    context,
+                    R.color.text_color_anti_secondary
+                ),
+                textColorWarning = ContextCompat.getColor(context, R.color.text_color_warning),
+                textColorSuccess = ContextCompat.getColor(context, R.color.text_color_success),
+                textColorError = ContextCompat.getColor(context, R.color.text_color_error),
+                // background
+                bgColorTopBar = ContextCompat.getColor(context, R.color.bg_color_top_bar),
+                bgColorOperate = ContextCompat.getColor(context, R.color.bg_color_operate),
+                bgColorDialog = ContextCompat.getColor(context, R.color.bg_color_dialog),
+                bgColorDialogModule = ContextCompat.getColor(
+                    context,
+                    R.color.bg_color_dialog_module
+                ),
+                bgColorEntryCard = ContextCompat.getColor(context, R.color.bg_color_entry_card),
+                bgColorFunction = ContextCompat.getColor(context, R.color.bg_color_function),
+                bgColorBottomBar = ContextCompat.getColor(context, R.color.bg_color_bottom_bar),
+                bgColorInput = ContextCompat.getColor(context, R.color.bg_color_input),
+                bgColorBubbleReciprocal = ContextCompat.getColor(
+                    context,
+                    R.color.bg_color_bubble_reciprocal
+                ),
+                bgColorBubbleOwn = ContextCompat.getColor(context, R.color.bg_color_bubble_own),
+                bgColorDefault = ContextCompat.getColor(context, R.color.bg_color_default),
+                bgColorTagMask = ContextCompat.getColor(context, R.color.bg_color_tag_mask),
+                bgColorElementMask = ContextCompat.getColor(context, R.color.bg_color_element_mask),
+                bgColorMask = ContextCompat.getColor(context, R.color.bg_color_mask),
+                bgColorMaskDisappeared = ContextCompat.getColor(
+                    context,
+                    R.color.bg_color_mask_disappeared
+                ),
+                bgColorMaskBegin = ContextCompat.getColor(context, R.color.bg_color_mask_begin),
+                bgColorAvatar = ContextCompat.getColor(context, R.color.bg_color_avatar),
+                // border
+                strokeColorPrimary = ContextCompat.getColor(context, R.color.stroke_color_primary),
+                strokeColorSecondary = ContextCompat.getColor(
+                    context,
+                    R.color.stroke_color_secondary
+                ),
+                strokeColorModule = ContextCompat.getColor(context, R.color.stroke_color_module),
+                // shadow
+                shadowColor = ContextCompat.getColor(context, R.color.shadow_color),
+                // status
+                listColorDefault = ContextCompat.getColor(context, R.color.list_color_default),
+                listColorHover = ContextCompat.getColor(context, R.color.list_color_hover),
+                listColorFocused = ContextCompat.getColor(context, R.color.list_color_focused),
+                // button
+                buttonColorPrimaryDefault = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_primary_default
+                ),
+                buttonColorPrimaryHover = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_primary_hover
+                ),
+                buttonColorPrimaryActive = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_primary_active
+                ),
+                buttonColorPrimaryDisabled = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_primary_disabled
+                ),
+                buttonColorSecondaryDefault = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_secondary_default
+                ),
+                buttonColorSecondaryHover = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_secondary_hover
+                ),
+                buttonColorSecondaryActive = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_secondary_active
+                ),
+                buttonColorSecondaryDisabled = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_secondary_disabled
+                ),
+                buttonColorAccept = ContextCompat.getColor(context, R.color.button_color_accept),
+                buttonColorHangupDefault = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_hangup_default
+                ),
+                buttonColorHangupDisabled = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_hangup_disabled
+                ),
+                buttonColorHangupHover = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_hangup_hover
+                ),
+                buttonColorHangupActive = ContextCompat.getColor(
+                    context,
+                    R.color.button_color_hangup_active
+                ),
+                buttonColorOn = ContextCompat.getColor(context, R.color.button_color_on),
+                buttonColorOff = ContextCompat.getColor(context, R.color.button_color_off),
+                // dropdown
+                dropdownColorDefault = ContextCompat.getColor(
+                    context,
+                    R.color.dropdown_color_default
+                ),
+                dropdownColorHover = ContextCompat.getColor(context, R.color.dropdown_color_hover),
+                dropdownColorActive = ContextCompat.getColor(
+                    context,
+                    R.color.dropdown_color_active
+                ),
+                // scrollbar
+                scrollbarColorDefault = ContextCompat.getColor(
+                    context,
+                    R.color.scrollbar_color_default
+                ),
+                scrollbarColorHover = ContextCompat.getColor(
+                    context,
+                    R.color.scrollbar_color_hover
+                ),
+                // floating
+                floatingColorDefault = ContextCompat.getColor(
+                    context,
+                    R.color.floating_color_default
+                ),
+                floatingColorOperate = ContextCompat.getColor(
+                    context,
+                    R.color.floating_color_operate
+                ),
+                // checkbox
+                checkboxColorSelected = ContextCompat.getColor(
+                    context,
+                    R.color.checkbox_color_selected
+                ),
+                // toast
+                toastColorWarning = ContextCompat.getColor(context, R.color.toast_color_warning),
+                toastColorSuccess = ContextCompat.getColor(context, R.color.toast_color_success),
+                toastColorError = ContextCompat.getColor(context, R.color.toast_color_error),
+                toastColorDefault = ContextCompat.getColor(context, R.color.toast_color_default),
+                // tag
+                tagColorLevel1 = ContextCompat.getColor(context, R.color.tag_color_level1),
+                tagColorLevel2 = ContextCompat.getColor(context, R.color.tag_color_level2),
+                tagColorLevel3 = ContextCompat.getColor(context, R.color.tag_color_level3),
+                tagColorLevel4 = ContextCompat.getColor(context, R.color.tag_color_level4),
+                // switch
+                switchColorOff = ContextCompat.getColor(context, R.color.switch_color_off),
+                switchColorOn = ContextCompat.getColor(context, R.color.switch_color_on),
+                switchColorButton = ContextCompat.getColor(context, R.color.switch_color_button),
+                // slider
+                sliderColorFilled = ContextCompat.getColor(context, R.color.slider_color_filled),
+                sliderColorEmpty = ContextCompat.getColor(context, R.color.slider_color_empty),
+                sliderColorButton = ContextCompat.getColor(context, R.color.slider_color_button),
+                // tab
+                tabColorSelected = ContextCompat.getColor(context, R.color.tab_color_selected),
+                tabColorUnselected = ContextCompat.getColor(context, R.color.tab_color_unselected),
+                tabColorOption = ContextCompat.getColor(context, R.color.tab_color_option)
+            )
+        }
+
+        fun generateLightTokens(context: Context, palette: List<Int>): ColorTokens {
+            val themeLight1 = palette[0]
+            val themeLight2 = palette[1]
+            val themeLight5 = palette[4]
+            val themeLight6 = palette[5]
+            val themeLight7 = palette[6]
+
+            return defaultLight(context).copy(
+                textColorLink = themeLight6,
+                textColorLinkHover = themeLight5,
+                textColorLinkActive = themeLight7,
+                textColorLinkDisabled = themeLight2,
+                bgColorBubbleOwn = themeLight2,
+                bgColorAvatar = themeLight2,
+                listColorFocused = themeLight1,
+                buttonColorPrimaryDefault = themeLight6,
+                buttonColorPrimaryHover = themeLight5,
+                buttonColorPrimaryActive = themeLight7,
+                buttonColorPrimaryDisabled = themeLight2,
+                dropdownColorActive = themeLight1,
+                checkboxColorSelected = themeLight6,
+                toastColorDefault = themeLight1,
+                tagColorLevel2 = themeLight5,
+                switchColorOn = themeLight6,
+                sliderColorFilled = themeLight6,
+                tabColorSelected = themeLight2
+            )
+        }
+
+        fun generateDarkTokens(context: Context, palette: List<Int>): ColorTokens {
+            val themeDark2 = palette[1]
+            val themeDark5 = palette[4]
+            val themeDark6 = palette[5]
+            val themeDark7 = palette[6]
+
+            return defaultDark(context).copy(
+                textColorLink = themeDark6,
+                textColorLinkHover = themeDark5,
+                textColorLinkActive = themeDark7,
+                textColorLinkDisabled = themeDark2,
+                bgColorBubbleOwn = themeDark7,
+                bgColorAvatar = themeDark2,
+                listColorFocused = themeDark2,
+                buttonColorPrimaryDefault = themeDark6,
+                buttonColorPrimaryHover = themeDark5,
+                buttonColorPrimaryActive = themeDark7,
+                buttonColorPrimaryDisabled = themeDark2,
+                dropdownColorActive = themeDark2,
+                checkboxColorSelected = themeDark5,
+                toastColorDefault = themeDark2,
+                tagColorLevel2 = themeDark5,
+                switchColorOn = themeDark5,
+                sliderColorFilled = themeDark5,
+                tabColorSelected = themeDark5
+            )
+        }
+
+        private val tokenNameToResIdMap = mapOf(
+            "textColorPrimary" to R.color.text_color_primary,
+            "textColorSecondary" to R.color.text_color_secondary,
+            "textColorTertiary" to R.color.text_color_tertiary,
+            "textColorDisable" to R.color.text_color_disable,
+            "textColorButton" to R.color.text_color_button,
+            "textColorButtonDisabled" to R.color.text_color_button_disabled,
+            "textColorLink" to R.color.text_color_link,
+            "textColorLinkHover" to R.color.text_color_link_hover,
+            "textColorLinkActive" to R.color.text_color_link_active,
+            "textColorLinkDisabled" to R.color.text_color_link_disabled,
+            "textColorAntiPrimary" to R.color.text_color_anti_primary,
+            "textColorAntiSecondary" to R.color.text_color_anti_secondary,
+            "textColorWarning" to R.color.text_color_warning,
+            "textColorSuccess" to R.color.text_color_success,
+            "textColorError" to R.color.text_color_error,
+            "bgColorTopBar" to R.color.bg_color_top_bar,
+            "bgColorOperate" to R.color.bg_color_operate,
+            "bgColorDialog" to R.color.bg_color_dialog,
+            "bgColorDialogModule" to R.color.bg_color_dialog_module,
+            "bgColorEntryCard" to R.color.bg_color_entry_card,
+            "bgColorFunction" to R.color.bg_color_function,
+            "bgColorBottomBar" to R.color.bg_color_bottom_bar,
+            "bgColorInput" to R.color.bg_color_input,
+            "bgColorBubbleReciprocal" to R.color.bg_color_bubble_reciprocal,
+            "bgColorBubbleOwn" to R.color.bg_color_bubble_own,
+            "bgColorDefault" to R.color.bg_color_default,
+            "bgColorTagMask" to R.color.bg_color_tag_mask,
+            "bgColorElementMask" to R.color.bg_color_element_mask,
+            "bgColorMask" to R.color.bg_color_mask,
+            "bgColorMaskDisappeared" to R.color.bg_color_mask_disappeared,
+            "bgColorMaskBegin" to R.color.bg_color_mask_begin,
+            "bgColorAvatar" to R.color.bg_color_avatar,
+            "strokeColorPrimary" to R.color.stroke_color_primary,
+            "strokeColorSecondary" to R.color.stroke_color_secondary,
+            "strokeColorModule" to R.color.stroke_color_module,
+            "shadowColor" to R.color.shadow_color,
+            "listColorDefault" to R.color.list_color_default,
+            "listColorHover" to R.color.list_color_hover,
+            "listColorFocused" to R.color.list_color_focused,
+            "buttonColorPrimaryDefault" to R.color.button_color_primary_default,
+            "buttonColorPrimaryHover" to R.color.button_color_primary_hover,
+            "buttonColorPrimaryActive" to R.color.button_color_primary_active,
+            "buttonColorPrimaryDisabled" to R.color.button_color_primary_disabled,
+            "buttonColorSecondaryDefault" to R.color.button_color_secondary_default,
+            "buttonColorSecondaryHover" to R.color.button_color_secondary_hover,
+            "buttonColorSecondaryActive" to R.color.button_color_secondary_active,
+            "buttonColorSecondaryDisabled" to R.color.button_color_secondary_disabled,
+            "buttonColorAccept" to R.color.button_color_accept,
+            "buttonColorHangupDefault" to R.color.button_color_hangup_default,
+            "buttonColorHangupDisabled" to R.color.button_color_hangup_disabled,
+            "buttonColorHangupHover" to R.color.button_color_hangup_hover,
+            "buttonColorHangupActive" to R.color.button_color_hangup_active,
+            "buttonColorOn" to R.color.button_color_on,
+            "buttonColorOff" to R.color.button_color_off,
+            "dropdownColorDefault" to R.color.dropdown_color_default,
+            "dropdownColorHover" to R.color.dropdown_color_hover,
+            "dropdownColorActive" to R.color.dropdown_color_active,
+            "scrollbarColorDefault" to R.color.scrollbar_color_default,
+            "scrollbarColorHover" to R.color.scrollbar_color_hover,
+            "floatingColorDefault" to R.color.floating_color_default,
+            "floatingColorOperate" to R.color.floating_color_operate,
+            "checkboxColorSelected" to R.color.checkbox_color_selected,
+            "toastColorWarning" to R.color.toast_color_warning,
+            "toastColorSuccess" to R.color.toast_color_success,
+            "toastColorError" to R.color.toast_color_error,
+            "toastColorDefault" to R.color.toast_color_default,
+            "tagColorLevel1" to R.color.tag_color_level1,
+            "tagColorLevel2" to R.color.tag_color_level2,
+            "tagColorLevel3" to R.color.tag_color_level3,
+            "tagColorLevel4" to R.color.tag_color_level4,
+            "switchColorOff" to R.color.switch_color_off,
+            "switchColorOn" to R.color.switch_color_on,
+            "switchColorButton" to R.color.switch_color_button,
+            "sliderColorFilled" to R.color.slider_color_filled,
+            "sliderColorEmpty" to R.color.slider_color_empty,
+            "sliderColorButton" to R.color.slider_color_button,
+            "tabColorSelected" to R.color.tab_color_selected,
+            "tabColorUnselected" to R.color.tab_color_unselected,
+            "tabColorOption" to R.color.tab_color_option
+        )
+
+        private val resIdToTokenNameMap by lazy {
+            tokenNameToResIdMap.entries.associate { it.value to it.key }
+        }
+
+        fun getTokenKeyFromColorResId(colorResId: Int): String? {
+            return resIdToTokenNameMap[colorResId]
+        }
+
+        fun parseColorAttribute(
+            attrs: AttributeSet?,
+            attrId: Int,
+            attrName: String,
+        ): String? {
+            if (attrs == null) return null
+
+            for (i in 0 until attrs.attributeCount) {
+                if (attrs.getAttributeNameResource(i) == attrId) {
+                    val resourceId = attrs.getAttributeResourceValue(i, -1)
+                    if (resourceId != -1) {
+                        return try {
+                            getTokenKeyFromColorResId(resourceId)
+                        } catch (e: Exception) {
+                            android.util.Log.e("ColorTokens", "Error parsing $attrName", e)
+                            null
+                        }
+                    }
+                    break
+                }
+            }
+            return null
+        }
+    }
+}

+ 34 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/tokens/DesignTokenSet.kt

@@ -0,0 +1,34 @@
+package io.trtc.tuikit.atomicx.theme.tokens
+
+import android.content.Context
+
+data class DesignTokenSet(
+    val id: String,
+    val displayName: String,
+    val color: ColorTokens,
+    val font: FontTokens,
+    val shadow: ShadowTokens
+) {
+    companion object {
+
+        fun defaultLight(context: Context): DesignTokenSet {
+            return DesignTokenSet(
+                id = "light",
+                displayName = "Light Theme",
+                color = ColorTokens.defaultLight(context),
+                font = FontTokens(),
+                shadow = ShadowTokens.standard()
+            )
+        }
+
+        fun defaultDark(context: Context): DesignTokenSet {
+            return DesignTokenSet(
+                id = "dark",
+                displayName = "Dark Theme",
+                color = ColorTokens.defaultDark(context),
+                font = FontTokens(),
+                shadow = ShadowTokens.standard()
+            )
+        }
+    }
+}

+ 36 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/tokens/FontTokens.kt

@@ -0,0 +1,36 @@
+package io.trtc.tuikit.atomicx.theme.tokens
+
+import android.graphics.Typeface
+
+data class Font(
+    val size: Float,
+    val weight: Int
+)
+
+data class FontTokens(
+    val bold40: Font = Font(size = 40f, weight = Typeface.BOLD),
+    val bold36: Font = Font(size = 36f, weight = Typeface.BOLD),
+    val bold34: Font = Font(size = 34f, weight = Typeface.BOLD),
+    val bold32: Font = Font(size = 32f, weight = Typeface.BOLD),
+    val bold28: Font = Font(size = 28f, weight = Typeface.BOLD),
+    val bold24: Font = Font(size = 24f, weight = Typeface.BOLD),
+    val bold20: Font = Font(size = 20f, weight = Typeface.BOLD),
+    val bold18: Font = Font(size = 18f, weight = Typeface.BOLD),
+    val bold16: Font = Font(size = 16f, weight = Typeface.BOLD),
+    val bold14: Font = Font(size = 14f, weight = Typeface.BOLD),
+    val bold12: Font = Font(size = 12f, weight = Typeface.BOLD),
+    val bold10: Font = Font(size = 10f, weight = Typeface.BOLD),
+
+    val regular40: Font = Font(size = 40f, weight = Typeface.NORMAL),
+    val regular36: Font = Font(size = 36f, weight = Typeface.NORMAL),
+    val regular34: Font = Font(size = 34f, weight = Typeface.NORMAL),
+    val regular32: Font = Font(size = 32f, weight = Typeface.NORMAL),
+    val regular28: Font = Font(size = 28f, weight = Typeface.NORMAL),
+    val regular24: Font = Font(size = 24f, weight = Typeface.NORMAL),
+    val regular20: Font = Font(size = 20f, weight = Typeface.NORMAL),
+    val regular18: Font = Font(size = 18f, weight = Typeface.NORMAL),
+    val regular16: Font = Font(size = 16f, weight = Typeface.NORMAL),
+    val regular14: Font = Font(size = 14f, weight = Typeface.NORMAL),
+    val regular12: Font = Font(size = 12f, weight = Typeface.NORMAL),
+    val regular10: Font = Font(size = 10f, weight = Typeface.NORMAL)
+)

+ 41 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/tokens/ShadowTokens.kt

@@ -0,0 +1,41 @@
+package io.trtc.tuikit.atomicx.theme.tokens
+
+import androidx.annotation.ColorInt
+
+data class ShadowStyle(
+    val elevation: Float,
+    @ColorInt val color: Int,
+    val x: Float = 0f,
+    val y: Float = 0f,
+    val radius: Float = 0f,
+    val opacity: Float = 1.0f
+)
+
+data class ShadowTokens(
+    val smallShadow: ShadowStyle,
+    val mediumShadow: ShadowStyle
+) {
+    companion object {
+
+        fun standard(): ShadowTokens {
+            return ShadowTokens(
+                smallShadow = ShadowStyle(
+                    elevation = 2f,
+                    color = 0x1F000000.toInt(),
+                    x = 0f,
+                    y = 2f,
+                    radius = 4f,
+                    opacity = 1.0f
+                ),
+                mediumShadow = ShadowStyle(
+                    elevation = 4f,
+                    color = 0x29000000.toInt(),
+                    x = 0f,
+                    y = 4f,
+                    radius = 8f,
+                    opacity = 1.0f
+                )
+            )
+        }
+    }
+}

+ 258 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/utils/ColorAlgorithm.kt

@@ -0,0 +1,258 @@
+package io.trtc.tuikit.atomicx.theme.utils
+
+import android.content.Context
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat
+import io.trtc.tuikit.atomicx.R
+import kotlin.math.*
+import androidx.core.graphics.toColorInt
+
+object ColorAlgorithm {
+    fun generateColorPalette(context: Context, baseColor: String, theme: String): List<Int> {
+        return if (isStandardColor(baseColor)) {
+            val paletteType = getClosestPaletteType(baseColor)
+            getPaletteFromResources(context, paletteType, theme)
+        } else {
+            generateDynamicColorVariations(baseColor, theme)
+        }
+    }
+
+    @ColorInt
+    fun parseHexColor(hexColor: String): Int {
+        val cleanHex = hexColor.removePrefix("#")
+        return "#$cleanHex".toColorInt()
+    }
+
+    private enum class PaletteType {
+        BLUE, GREEN, RED, ORANGE
+    }
+
+    private fun getPaletteFromResources(
+        context: Context,
+        type: PaletteType,
+        theme: String
+    ): List<Int> {
+        val colorIds = when (type) {
+            PaletteType.BLUE -> if (theme == "dark") {
+                listOf(
+                    R.color.theme_dark_1, R.color.theme_dark_2, R.color.theme_dark_3,
+                    R.color.theme_dark_4, R.color.theme_dark_5, R.color.theme_dark_6,
+                    R.color.theme_dark_7, R.color.theme_dark_8, R.color.theme_dark_9,
+                    R.color.theme_dark_10
+                )
+            } else {
+                listOf(
+                    R.color.theme_light_1, R.color.theme_light_2, R.color.theme_light_3,
+                    R.color.theme_light_4, R.color.theme_light_5, R.color.theme_light_6,
+                    R.color.theme_light_7, R.color.theme_light_8, R.color.theme_light_9,
+                    R.color.theme_light_10
+                )
+            }
+
+            PaletteType.GREEN -> if (theme == "dark") {
+                listOf(
+                    R.color.green_dark_1, R.color.green_dark_2, R.color.green_dark_3,
+                    R.color.green_dark_4, R.color.green_dark_5, R.color.green_dark_6,
+                    R.color.green_dark_7, R.color.green_dark_8, R.color.green_dark_9,
+                    R.color.green_dark_10
+                )
+            } else {
+                listOf(
+                    R.color.green_light_1, R.color.green_light_2, R.color.green_light_3,
+                    R.color.green_light_4, R.color.green_light_5, R.color.green_light_6,
+                    R.color.green_light_7, R.color.green_light_8, R.color.green_light_9,
+                    R.color.green_light_10
+                )
+            }
+
+            PaletteType.RED -> if (theme == "dark") {
+                listOf(
+                    R.color.red_dark_1, R.color.red_dark_2, R.color.red_dark_3,
+                    R.color.red_dark_4, R.color.red_dark_5, R.color.red_dark_6,
+                    R.color.red_dark_7, R.color.red_dark_8, R.color.red_dark_9,
+                    R.color.red_dark_10
+                )
+            } else {
+                listOf(
+                    R.color.red_light_1, R.color.red_light_2, R.color.red_light_3,
+                    R.color.red_light_4, R.color.red_light_5, R.color.red_light_6,
+                    R.color.red_light_7, R.color.red_light_8, R.color.red_light_9,
+                    R.color.red_light_10
+                )
+            }
+
+            PaletteType.ORANGE -> if (theme == "dark") {
+                listOf(
+                    R.color.orange_dark_1, R.color.orange_dark_2, R.color.orange_dark_3,
+                    R.color.orange_dark_4, R.color.orange_dark_5, R.color.orange_dark_6,
+                    R.color.orange_dark_7, R.color.orange_dark_8, R.color.orange_dark_9,
+                    R.color.orange_dark_10
+                )
+            } else {
+                listOf(
+                    R.color.orange_light_1, R.color.orange_light_2, R.color.orange_light_3,
+                    R.color.orange_light_4, R.color.orange_light_5, R.color.orange_light_6,
+                    R.color.orange_light_7, R.color.orange_light_8, R.color.orange_light_9,
+                    R.color.orange_light_10
+                )
+            }
+        }
+
+        return colorIds.map { ContextCompat.getColor(context, it) }
+    }
+
+    private val STANDARD_COLORS = mapOf(
+        PaletteType.BLUE to "#1c66e5",
+        PaletteType.GREEN to "#0abf77",
+        PaletteType.RED to "#e54545",
+        PaletteType.ORANGE to "#ff7200"
+    )
+
+    private fun isStandardColor(color: String): Boolean {
+        val inputHsl = hexToHSL(color)
+        return STANDARD_COLORS.values.any { standardColor ->
+            val standardHsl = hexToHSL(standardColor)
+            val dh =
+                min(
+                    abs(inputHsl.first - standardHsl.first),
+                    360 - abs(inputHsl.first - standardHsl.first)
+                )
+            dh < 15 && abs(inputHsl.second - standardHsl.second) < 15 && abs(inputHsl.third - standardHsl.third) < 15
+        }
+    }
+
+    private fun getClosestPaletteType(color: String): PaletteType {
+        val hsl = hexToHSL(color)
+
+        val distances = STANDARD_COLORS.map { (type, baseColor) ->
+            type to colorDistance(hsl, hexToHSL(baseColor))
+        }
+
+        return distances.minByOrNull { it.second }?.first ?: PaletteType.BLUE
+    }
+
+    private val HSL_ADJUSTMENTS = mapOf(
+        "light" to mapOf(
+            1 to Pair(-40.0, 45.0),
+            2 to Pair(-30.0, 35.0),
+            3 to Pair(-20.0, 25.0),
+            4 to Pair(-10.0, 15.0),
+            5 to Pair(-5.0, 5.0),
+            6 to Pair(0.0, 0.0),
+            7 to Pair(5.0, -10.0),
+            8 to Pair(10.0, -20.0),
+            9 to Pair(15.0, -30.0),
+            10 to Pair(20.0, -40.0)
+        ),
+        "dark" to mapOf(
+            1 to Pair(-60.0, -35.0),
+            2 to Pair(-50.0, -25.0),
+            3 to Pair(-40.0, -15.0),
+            4 to Pair(-30.0, -5.0),
+            5 to Pair(-20.0, 5.0),
+            6 to Pair(0.0, 0.0),
+            7 to Pair(-10.0, 15.0),
+            8 to Pair(-20.0, 30.0),
+            9 to Pair(-30.0, 45.0),
+            10 to Pair(-40.0, 60.0)
+        )
+    )
+
+    private fun generateDynamicColorVariations(baseColor: String, theme: String): List<Int> {
+        val variations = mutableListOf<Int>()
+        val adjustments = HSL_ADJUSTMENTS[theme] ?: HSL_ADJUSTMENTS["light"]!!
+        val baseHsl = hexToHSL(baseColor)
+        val saturationFactor = when {
+            baseHsl.second > 70 -> 0.8
+            baseHsl.second < 30 -> 1.2
+            else -> 1.0
+        }
+        val lightnessFactor = when {
+            baseHsl.third > 70 -> 0.8
+            baseHsl.third < 30 -> 1.2
+            else -> 1.0
+        }
+
+        for (i in 1..10) {
+            val adjustment = adjustments[i] ?: Pair(0.0, 0.0)
+            val adjustedS = adjustment.first * saturationFactor
+            val adjustedL = adjustment.second * lightnessFactor
+            variations.add(parseHexColor(adjustColor(baseColor, Pair(adjustedS, adjustedL))))
+        }
+
+        return variations
+    }
+
+    private fun adjustColor(color: String, adjustment: Pair<Double, Double>): String {
+        val hsl = hexToHSL(color)
+        val newS = max(0.0, min(100.0, hsl.second + adjustment.first))
+        val newL = max(0.0, min(100.0, hsl.third + adjustment.second))
+        return hslToHex(hsl.first, newS, newL)
+    }
+
+    private fun colorDistance(
+        c1: Triple<Double, Double, Double>,
+        c2: Triple<Double, Double, Double>
+    ): Double {
+        val dh = min(abs(c1.first - c2.first), 360 - abs(c1.first - c2.first))
+        val ds = c1.second - c2.second
+        val dl = c1.third - c2.third
+        return sqrt(dh * dh + ds * ds + dl * dl)
+    }
+
+    private fun hexToHSL(hex: String): Triple<Double, Double, Double> {
+        val cleanHex = hex.removePrefix("#")
+        val colorInt = cleanHex.toLong(16)
+
+        val r = ((colorInt shr 16) and 0xFF) / 255.0
+        val g = ((colorInt shr 8) and 0xFF) / 255.0
+        val b = (colorInt and 0xFF) / 255.0
+
+        val maxVal = maxOf(r, g, b)
+        val minVal = minOf(r, g, b)
+        var h = 0.0
+        var s = 0.0
+        val l = (maxVal + minVal) / 2.0
+
+        if (maxVal != minVal) {
+            val d = maxVal - minVal
+            s = if (l > 0.5) d / (2.0 - maxVal - minVal) else d / (maxVal + minVal)
+
+            h = when (maxVal) {
+                r -> (g - b) / d + (if (g < b) 6.0 else 0.0)
+                g -> (b - r) / d + 2.0
+                b -> (r - g) / d + 4.0
+                else -> 0.0
+            }
+            h /= 6.0
+        }
+
+        return Triple(h * 360.0, s * 100.0, l * 100.0)
+    }
+
+    private fun hslToHex(h: Double, s: Double, l: Double): String {
+        val hNorm = h / 360.0
+        val sNorm = s / 100.0
+        val lNorm = l / 100.0
+
+        val c = (1.0 - abs(2.0 * lNorm - 1.0)) * sNorm
+        val x = c * (1.0 - abs((hNorm * 6.0) % 2.0 - 1.0))
+        val m = lNorm - c / 2.0
+
+        val (r, g, b) = when ((hNorm * 6.0).toInt()) {
+            0 -> Triple(c, x, 0.0)
+            1 -> Triple(x, c, 0.0)
+            2 -> Triple(0.0, c, x)
+            3 -> Triple(0.0, x, c)
+            4 -> Triple(x, 0.0, c)
+            5 -> Triple(c, 0.0, x)
+            else -> Triple(0.0, 0.0, 0.0)
+        }
+
+        val red = ((r + m) * 255.0).toInt()
+        val green = ((g + m) * 255.0).toInt()
+        val blue = ((b + m) * 255.0).toInt()
+
+        return "#%02X%02X%02X".format(red, green, blue)
+    }
+}

+ 65 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/theme/utils/ThemePersistUtil.kt

@@ -0,0 +1,65 @@
+package io.trtc.tuikit.atomicx.theme.utils
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+
+class ThemePersistUtil(context: Context) {
+    
+    private val sharedPreferences: SharedPreferences = 
+        context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+    
+    companion object {
+        private const val PREFS_NAME = "theme_preferences"
+        private const val KEY_CURRENT_THEME_ID = "current_theme_id"
+        private const val KEY_USER_HAS_MANUALLY_SET_THEME = "user_has_manually_set_theme"
+        private const val KEY_FOLLOW_SYSTEM_APPEARANCE = "follow_system_appearance"
+        private const val KEY_CUSTOM_PRIMARY_COLOR = "custom_primary_color"
+    }
+
+    fun getCurrentThemeId(): String? {
+        return sharedPreferences.getString(KEY_CURRENT_THEME_ID, null)
+    }
+
+    fun setCurrentThemeId(themeId: String) {
+        sharedPreferences.edit {
+            putString(KEY_CURRENT_THEME_ID, themeId)
+        }
+    }
+
+    fun getUserHasManuallySetTheme(): Boolean {
+        return sharedPreferences.getBoolean(KEY_USER_HAS_MANUALLY_SET_THEME, false)
+    }
+
+    fun setUserHasManuallySetTheme(hasSet: Boolean) {
+        sharedPreferences.edit {
+            putBoolean(KEY_USER_HAS_MANUALLY_SET_THEME, hasSet)
+        }
+    }
+
+    fun getFollowSystemAppearance(): Boolean {
+        return sharedPreferences.getBoolean(KEY_FOLLOW_SYSTEM_APPEARANCE, true)
+    }
+
+    fun setFollowSystemAppearance(follow: Boolean) {
+        sharedPreferences.edit {
+            putBoolean(KEY_FOLLOW_SYSTEM_APPEARANCE, follow)
+        }
+    }
+
+    fun getCustomPrimaryColor(): String? {
+        return sharedPreferences.getString(KEY_CUSTOM_PRIMARY_COLOR, null)
+    }
+
+    fun setCustomPrimaryColor(hexColor: String) {
+        sharedPreferences.edit {
+            putString(KEY_CUSTOM_PRIMARY_COLOR, hexColor)
+        }
+    }
+
+    fun clearCustomPrimaryColor() {
+        sharedPreferences.edit {
+            remove(KEY_CUSTOM_PRIMARY_COLOR)
+        }
+    }
+}

+ 481 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/alertdialog/AtomicAlertDialog.kt

@@ -0,0 +1,481 @@
+package io.trtc.tuikit.atomicx.widget.basicwidget.alertdialog
+
+import android.app.Dialog
+import android.content.Context
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.annotation.ColorInt
+import androidx.core.view.setPadding
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.theme.ThemeStore
+import io.trtc.tuikit.atomicx.theme.tokens.ColorTokens
+import io.trtc.tuikit.atomicx.theme.tokens.DesignTokenSet
+import io.trtc.tuikit.atomicx.widget.basicwidget.popover.AtomicPopover
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.Job
+import android.graphics.Color
+
+class AtomicAlertDialog(
+    private val context: Context,
+    private val gravity: AtomicPopover.PanelGravity = AtomicPopover.PanelGravity.CENTER,
+) {
+    private val DIVIDER_THICKNESS_PX = 1
+
+    private lateinit var rootLayout: LinearLayout
+    private lateinit var config: DialogConfig
+    private var atomicPopover: AtomicPopover? = null
+    private var dialogScope: CoroutineScope? = null
+    private var countdownJob: Job? = null
+    private var cancelButtonView: TextView? = null
+    private var originalCancelText: String = ""
+
+    enum class TextColorPreset {
+        PRIMARY, GREY, BLUE, RED
+    }
+
+    data class DialogConfig(
+        var title: String = "",
+        var content: String? = null,
+        var iconView: View? = null,
+        var autoDismiss: Boolean = false,
+        var countdownDuration: Long = 0,
+        var confirmConfig: ButtonConfig? = null,
+        var cancelConfig: ButtonConfig? = null,
+        val itemList: MutableList<ButtonConfig> = mutableListOf(),
+    )
+
+    class ButtonConfig(
+        var text: String,
+        var type: TextColorPreset,
+        var onClick: ((Dialog) -> Unit)?,
+        var isBold: Boolean,
+    )
+
+    fun init(block: DialogConfig.() -> Unit) {
+        config = DialogConfig().apply(block)
+        val tokens = getCurrentTokens(context)
+        rootLayout = createRootLayout(tokens)
+        renderDialogContent(rootLayout, config, null, tokens)
+    }
+
+    fun show(): Dialog {
+        atomicPopover?.dismiss()
+        dialogScope?.cancel()
+        countdownJob?.cancel()
+        dialogScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
+        val panel = AtomicPopover(context, gravity)
+
+        atomicPopover = panel.apply {
+            setContent(rootLayout)
+            setCancelable(config.autoDismiss)
+            setCanceledOnTouchOutside(config.autoDismiss)
+            setOnDismissListener {
+                atomicPopover = null
+                countdownJob?.cancel()
+            }
+        }
+
+        dialogScope?.launch {
+            ThemeStore.shared(context).themeState.collectLatest {
+                if (panel.isShowing) {
+                    val newTokens = getCurrentTokens(context)
+                    updateDialogTheme(rootLayout, config, panel, newTokens)
+                }
+            }
+        }
+
+        atomicPopover?.show()
+        
+        if (config.countdownDuration > 0 && cancelButtonView != null) {
+            startCountdown(config.countdownDuration)
+        }
+        
+        return atomicPopover!!
+    }
+
+    fun dismiss() {
+        dialogScope?.cancel()
+        dialogScope = null
+        countdownJob?.cancel()
+        countdownJob = null
+        atomicPopover?.dismiss()
+        atomicPopover = null
+    }
+
+    fun isShowing(): Boolean {
+        return atomicPopover?.isShowing == true
+    }
+
+    private fun getCurrentTokens(context: Context): DesignTokenSet {
+        return ThemeStore.shared(context).themeState.value.currentTheme.tokens
+    }
+
+    private fun createRootLayout(tokens: DesignTokenSet): LinearLayout {
+        return LinearLayout(context).apply {
+            orientation = LinearLayout.VERTICAL
+        }
+    }
+
+    private fun renderDialogContent(
+        root: LinearLayout,
+        config: DialogConfig,
+        dialog: Dialog?,
+        tokens: DesignTokenSet,
+    ) {
+        root.removeAllViews()
+        renderHeaderSection(root, config, tokens)
+        renderBottomSection(root, config, dialog, tokens)
+    }
+
+    private fun updateDialogTheme(
+        root: LinearLayout,
+        config: DialogConfig,
+        dialog: Dialog,
+        tokens: DesignTokenSet,
+    ) {
+        root.removeAllViews()
+        renderDialogContent(root, config, dialog, tokens)
+    }
+
+    private fun renderHeaderSection(
+        root: LinearLayout,
+        config: DialogConfig,
+        tokens: DesignTokenSet,
+    ) {
+        val headerContainer = createHeaderContainer()
+        root.addView(headerContainer)
+        val titleWrapper = createTitleWrapper()
+        headerContainer.addView(titleWrapper)
+        renderIconIfPresent(titleWrapper, config)
+        renderTitleText(titleWrapper, config, tokens)
+        renderMessageContentIfPresent(headerContainer, config, tokens)
+    }
+
+    private fun createHeaderContainer(): LinearLayout {
+        return LinearLayout(context).apply {
+            orientation = LinearLayout.VERTICAL
+            gravity = Gravity.CENTER
+            val paddingPx = context.resources.getDimensionPixelSize(R.dimen.spacing_24)
+            setPadding(paddingPx, paddingPx, paddingPx, paddingPx)
+        }
+    }
+
+    private fun createTitleWrapper(): LinearLayout {
+        return LinearLayout(context).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            layoutParams = LinearLayout.LayoutParams(
+                ViewGroup.LayoutParams.WRAP_CONTENT,
+                ViewGroup.LayoutParams.WRAP_CONTENT
+            )
+        }
+    }
+
+    private fun renderIconIfPresent(
+        parent: ViewGroup,
+        config: DialogConfig,
+    ) {
+        val iconView = config.iconView
+        if (iconView != null) {
+            val size = context.resources.getDimensionPixelSize(R.dimen.spacing_20)
+            iconView.layoutParams = LinearLayout.LayoutParams(size, size).apply {
+                marginEnd = context.resources.getDimensionPixelSize(R.dimen.spacing_8)
+            }
+            (iconView.parent as? ViewGroup)?.removeView(iconView)
+            parent.addView(iconView)
+        }
+    }
+
+    private fun renderTitleText(parent: ViewGroup, config: DialogConfig, tokens: DesignTokenSet) {
+        val finalColor = tokens.color.textColorPrimary
+        val finalSize = tokens.font.bold16.size
+        val tvTitle = TextView(context).apply {
+            text = config.title
+            textSize = finalSize
+            typeface = Typeface.DEFAULT
+            setTextColor(finalColor)
+            gravity = Gravity.CENTER
+        }
+        parent.addView(tvTitle)
+    }
+
+    private fun renderMessageContentIfPresent(
+        parent: ViewGroup,
+        config: DialogConfig,
+        tokens: DesignTokenSet,
+    ) {
+        if (!config.content.isNullOrEmpty()) {
+            val finalColor = tokens.color.textColorSecondary
+            val finalSize = tokens.font.bold16.size
+            val tvContent = TextView(context).apply {
+                text = config.content
+                textSize = finalSize
+                setTextColor(finalColor)
+                gravity = Gravity.CENTER
+                val topPaddingPx = context.resources.getDimensionPixelSize(R.dimen.spacing_16)
+                setPadding(0, topPaddingPx, 0, 0)
+            }
+            parent.addView(tvContent)
+        }
+    }
+
+    private fun renderBottomSection(
+        root: LinearLayout,
+        config: DialogConfig,
+        dialog: Dialog?,
+        tokens: DesignTokenSet,
+    ) {
+        if (config.itemList.isNotEmpty()) {
+            renderVerticalListMode(root, config, dialog, tokens)
+        } else if (config.confirmConfig != null || config.cancelConfig != null) {
+            renderStandardButtonMode(root, config, dialog, tokens)
+        }
+    }
+
+    private fun renderVerticalListMode(
+        root: LinearLayout,
+        config: DialogConfig,
+        dialog: Dialog?,
+        tokens: DesignTokenSet,
+    ) {
+        if (config.itemList.isNotEmpty()) {
+            addHorizontalDivider(root, tokens.color)
+        }
+        config.itemList.forEachIndexed { index, itemConfig ->
+            if (index > 0) addHorizontalDivider(root, tokens.color)
+            val isLastItem = index == config.itemList.lastIndex
+            val itemBtn = createButtonView(
+                itemConfig,
+                tokens,
+                isBottomLeftRounded = isLastItem && gravity == AtomicPopover.PanelGravity.CENTER,
+                isBottomRightRounded = isLastItem && gravity == AtomicPopover.PanelGravity.CENTER
+            )
+            val heightPx = context.resources.getDimensionPixelSize(R.dimen.spacing_56)
+            itemBtn.layoutParams = LinearLayout.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                heightPx
+            )
+            itemBtn.setOnClickListener {
+                itemConfig.onClick?.invoke(dialog!!)
+                dialog?.dismiss()
+            }
+            root.addView(itemBtn)
+        }
+    }
+
+    private fun renderStandardButtonMode(
+        root: LinearLayout,
+        config: DialogConfig,
+        dialog: Dialog?,
+        tokens: DesignTokenSet,
+    ) {
+        addHorizontalDivider(root, tokens.color)
+
+        val heightPx = context.resources.getDimensionPixelSize(R.dimen.spacing_56)
+        val buttonContainer = LinearLayout(context).apply {
+            orientation = LinearLayout.HORIZONTAL
+            layoutParams = LinearLayout.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                heightPx
+            )
+        }
+        root.addView(buttonContainer)
+
+        val hasCancel = config.cancelConfig != null
+        val hasConfirm = config.confirmConfig != null
+        val isCenterGravity = gravity == AtomicPopover.PanelGravity.CENTER
+
+        config.cancelConfig?.let { btnConfig ->
+            val isOnlyCancel = !hasConfirm
+            originalCancelText = btnConfig.text
+            val displayText = if (config.countdownDuration > 0) {
+                "$originalCancelText (${config.countdownDuration})"
+            } else {
+                btnConfig.text
+            }
+            val displayConfig = ButtonConfig(displayText, btnConfig.type, btnConfig.onClick, btnConfig.isBold)
+            val btnView = createButtonView(
+                displayConfig,
+                tokens,
+                isBottomLeftRounded = isCenterGravity,
+                isBottomRightRounded = isOnlyCancel && isCenterGravity
+            )
+            cancelButtonView = btnView
+            btnView.setOnClickListener {
+                btnConfig.onClick?.invoke(dialog ?: return@setOnClickListener)
+                dialog?.dismiss()
+            }
+            buttonContainer.addView(
+                btnView,
+                LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
+            )
+        }
+
+        if (hasCancel && hasConfirm) {
+            addVerticalDivider(buttonContainer, tokens.color)
+        }
+
+        config.confirmConfig?.let { btnConfig ->
+            val isOnlyConfirm = !hasCancel
+            val btnView = createButtonView(
+                btnConfig,
+                tokens,
+                isBottomLeftRounded = isOnlyConfirm && isCenterGravity,
+                isBottomRightRounded = isCenterGravity
+            )
+            btnView.setOnClickListener {
+                btnConfig.onClick?.invoke(dialog ?: return@setOnClickListener)
+                dialog?.dismiss()
+            }
+            buttonContainer.addView(
+                btnView,
+                LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
+            )
+        }
+    }
+
+    private fun addHorizontalDivider(root: ViewGroup, colorTokens: ColorTokens) {
+        val view = View(context).apply {
+            layoutParams =
+                LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, DIVIDER_THICKNESS_PX)
+            setBackgroundColor(colorTokens.strokeColorSecondary)
+        }
+        root.addView(view)
+    }
+
+    private fun addVerticalDivider(root: ViewGroup, colorTokens: ColorTokens) {
+        val view = View(context).apply {
+            layoutParams =
+                LinearLayout.LayoutParams(DIVIDER_THICKNESS_PX, ViewGroup.LayoutParams.MATCH_PARENT)
+            setBackgroundColor(colorTokens.strokeColorSecondary)
+        }
+        root.addView(view)
+    }
+
+    private fun createButtonView(
+        config: ButtonConfig,
+        tokens: DesignTokenSet,
+        isBottomLeftRounded: Boolean = false,
+        isBottomRightRounded: Boolean = false,
+    ): TextView {
+        val finalSize = tokens.font.bold16.size
+        val buttonView = TextView(context).apply {
+            text = config.text
+            textSize = finalSize
+            gravity = Gravity.CENTER
+            val finalColor = resolveButtonTextColor(config.type, tokens.color)
+            setTextColor(finalColor)
+            typeface = if (config.isBold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
+        }
+
+        buttonView.setBackgroundColor(Color.TRANSPARENT)
+
+        if (isBottomLeftRounded || isBottomRightRounded) {
+            val cornerRadiusPx = context.resources.getDimension(R.dimen.radius_20)
+            val br = if (isBottomRightRounded) cornerRadiusPx else 0f
+            val bl = if (isBottomLeftRounded) cornerRadiusPx else 0f
+
+            val radii = floatArrayOf(
+                0f, 0f,
+                0f, 0f,
+                br, br,
+                bl, bl
+            )
+
+            val drawable = GradientDrawable().apply {
+                setColor(Color.TRANSPARENT)
+                cornerRadii = radii
+            }
+            buttonView.background = drawable
+        }
+
+        return buttonView
+    }
+
+    @ColorInt
+    private fun resolveButtonTextColor(type: TextColorPreset, colorTokens: ColorTokens): Int {
+        return when (type) {
+            TextColorPreset.RED -> colorTokens.textColorError
+            TextColorPreset.BLUE -> colorTokens.textColorLink
+            TextColorPreset.PRIMARY -> colorTokens.textColorPrimary
+            TextColorPreset.GREY -> colorTokens.textColorSecondary
+        }
+    }
+
+    private fun startCountdown(durationSeconds: Long) {
+        countdownJob?.cancel()
+        countdownJob = dialogScope?.launch {
+            var remaining = durationSeconds
+            while (remaining >= 1 && atomicPopover?.isShowing == true) {
+                cancelButtonView?.text = "$originalCancelText ($remaining)"
+                delay(1000)
+                remaining--
+            }
+            if (atomicPopover?.isShowing == true) {
+                atomicPopover?.dismiss()
+            }
+        }
+    }
+}
+
+fun AtomicAlertDialog.DialogConfig.init(
+    title: String,
+    content: String? = null,
+    iconView: View? = null,
+    autoDismiss: Boolean = false,
+) {
+    this.title = title
+    this.content = content
+    this.iconView = iconView
+    this.autoDismiss = autoDismiss
+}
+
+fun AtomicAlertDialog.DialogConfig.addItem(
+    text: String,
+    type: AtomicAlertDialog.TextColorPreset = AtomicAlertDialog.TextColorPreset.GREY,
+    isBold: Boolean = false,
+    onClick: ((Dialog) -> Unit)? = null,
+) {
+    val config = AtomicAlertDialog.ButtonConfig(text, type, onClick, isBold)
+    itemList.add(config)
+}
+
+fun AtomicAlertDialog.DialogConfig.items(
+    items: List<Pair<String, AtomicAlertDialog.TextColorPreset>>,
+    isBold: Boolean = false,
+    onClick: (Dialog, Int, String) -> Unit,
+) {
+    items.forEachIndexed { index, (text, type) ->
+        addItem(text, type, isBold) { dialog -> onClick(dialog, index, text) }
+    }
+}
+
+fun AtomicAlertDialog.DialogConfig.confirmButton(
+    text: String,
+    type: AtomicAlertDialog.TextColorPreset = AtomicAlertDialog.TextColorPreset.BLUE,
+    isBold: Boolean = false,
+    onClick: ((Dialog) -> Unit)? = null,
+) {
+    this.confirmConfig = AtomicAlertDialog.ButtonConfig(text, type, onClick, isBold)
+}
+
+fun AtomicAlertDialog.DialogConfig.cancelButton(
+    text: String,
+    type: AtomicAlertDialog.TextColorPreset = AtomicAlertDialog.TextColorPreset.GREY,
+    isBold: Boolean = false,
+    onClick: ((Dialog) -> Unit)? = null,
+) {
+    this.cancelConfig = AtomicAlertDialog.ButtonConfig(text, type, onClick, isBold)
+}

+ 124 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/alertdialog/README.md

@@ -0,0 +1,124 @@
+# AtomicAlertDialog 通用弹窗组件
+
+`AtomicAlertDialog` 是 AtomicX Android UIKit 中的标准弹窗组件,基于 Kotlin DSL 设计,支持链式配置,默认提供确认/取消按钮、列表项等多种呈现方式。内部复用 `AtomicPopover` 的容器能力,可选择居中或底部弹出(通过构造参数传入 `AtomicPopover.PanelGravity`)。
+
+## 文件结构
+
+```
+basicwidget/alertdialog/
+├── AtomicAlertDialog.kt   # 组件实现(DSL + 渲染逻辑)
+└── README.md              # 本文档
+```
+
+## 快速开始
+
+使用方式:**先创建实例并在 `init {}` 中配置内容,再调用 `show()` 展示**。
+
+### 1. 基础确认/取消弹窗
+
+```kotlin
+val dialog = AtomicAlertDialog(context)
+dialog.init {
+    init(
+        title = "退出登录",
+        content = "确定要退出当前账号吗?",
+        autoDismiss = true
+    )
+
+    cancelButton(text = "取消")
+
+    confirmButton(
+        text = "确定",
+        type = AtomicAlertDialog.TextColorPreset.RED
+    ) { dlg ->
+        logout()
+    }
+}
+dialog.show()
+```
+
+### 2. 列表选择弹窗
+
+```kotlin
+val dialog = AtomicAlertDialog(context)
+dialog.init {
+    init(title = "设置头像")
+
+    val options = listOf(
+        "拍摄" to AtomicAlertDialog.TextColorPreset.PRIMARY,
+        "从相册选择" to AtomicAlertDialog.TextColorPreset.PRIMARY,
+        "删除头像" to AtomicAlertDialog.TextColorPreset.RED
+    )
+
+    items(options) { dlg, index, text ->
+        when (index) {
+            0 -> takePhoto()
+            1 -> pickImage()
+            2 -> deleteAvatar()
+        }
+    }
+}
+dialog.show()
+```
+
+### 3. 带自定义图标 View 的弹窗
+
+```kotlin
+val dialog = AtomicAlertDialog(context)
+val avatarView = AvatarView(context).apply { loadUrl("https://example.com/avatar.png") }
+
+dialog.init {
+    init(
+        title = "账号风险",
+        content = "检测到异常登录行为,请及时处理",
+        iconView = avatarView
+    )
+
+    confirmButton(text = "去处理")
+    cancelButton(text = "忽略", type = AtomicAlertDialog.TextColorPreset.GREY)
+}
+dialog.show()
+```
+
+## 核心 API(DialogConfig DSL)
+
+`init {}` 闭包中的 `DialogConfig` 支持以下配置:
+
+### 基础配置 `init(...)`
+
+| 参数 | 类型 | 说明 | 默认值 |
+| :--- | :--- | :--- | :--- |
+| `title` | `String` | 弹窗标题 | 必填 |
+| `content` | `String?` | 正文内容 | `null` |
+| `iconView` | `View?` | 自定义图标/头像 | `null` |
+| `autoDismiss` | `Boolean` | 点击按钮或列表项后是否自动关闭 | `true` |
+
+### 按钮配置
+
+- `confirmButton(text, type, isBold, onClick)`:主操作按钮(默认蓝色、加粗)
+- `cancelButton(text, type, isBold, onClick)`:次操作按钮(默认灰色)
+
+### 列表项
+
+- `addItem(text, type, isBold, onClick)`:添加单个列表项
+- `items(list, isBold, onClick)`:批量添加列表项,回调提供 `(dialog, index, text)`
+
+### 颜色预设 `TextColorPreset`
+
+- `PRIMARY`:主文本色
+- `GREY`:次级文本色
+- `BLUE`:强调/链接色
+- `RED`:危险/警示操作
+
+## 动态主题与布局
+
+- 内建监听 `ThemeStore`,当主题发生变化时自动刷新背景、文字、分割线等样式。
+- 通过构造参数 `AtomicAlertDialog(context, gravity = AtomicPopover.PanelGravity.CENTER)` 可切换弹窗出现的位置(例如居中、底部)。
+- 与 `AtomicPopover` 共享容器能力,确保圆角、遮罩、动画等表现一致。
+
+## 注意事项
+
+1. **Context**:建议传入 `Activity` Context;如需在 `Fragment` 中使用请确保生命周期安全。
+2. **Icon View**:传入的 `iconView` 不能同时挂在其他父容器下,内部会自动移除再添加。
+3. **自动关闭**:`autoDismiss = true` 时,`confirmButton`、`cancelButton`、`itemList` 的点击都会在回调后自动关闭;若需要手动控制请设置为 `false` 并在回调中自行 `dismiss()`。
+4. **重复 show**:再次 `show()` 前会自动销毁上一次的 `AtomicPopover`,无需手动清理。

+ 483 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/avatar/AtomicAvatar.kt

@@ -0,0 +1,483 @@
+package io.trtc.tuikit.atomicx.widget.basicwidget.avatar
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.graphics.Canvas
+import android.graphics.Outline
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewOutlineProvider
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.theme.ThemeStore
+import io.trtc.tuikit.atomicx.widget.utils.DisplayUtil.dp2px
+import io.trtc.tuikit.atomicx.widget.utils.ImageLoader
+import kotlin.math.ceil
+
+class AtomicAvatar @JvmOverloads constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0
+) : ViewGroup(context, attrs, defStyleAttr) {
+
+    companion object {
+        private const val SQRT2_OVER_2 = 0.70710678f
+        private const val BADGE_EXTRA_PADDING_DP = 2f
+    }
+
+    sealed class AvatarContent {
+        data class URL(val url: String, @DrawableRes val placeImage: Int) : AvatarContent()
+        data class Text(val name: String) : AvatarContent()
+        data class Icon(val drawable: Drawable) : AvatarContent()
+    }
+
+    sealed class AvatarBadge {
+        object None : AvatarBadge()
+        object Dot : AvatarBadge()
+        data class Text(val text: String) : AvatarBadge()
+    }
+
+    enum class AvatarSize(
+        val sizeDp: Float,
+        val textSizeSp: Float,
+        val borderRadiusDp: Float
+    ) {
+        XXS(16f, 10f, 4f),
+        XS(24f, 12f, 4f),
+        S(32f, 14f, 4f),
+        M(40f, 16f, 4f),
+        L(48f, 18f, 8f),
+        XL(64f, 28f, 12f),
+        XXL(96f, 36f, 12f)
+    }
+
+    enum class AvatarShape {
+        Round,
+        RoundRectangle,
+        Rectangle
+    }
+
+    private val themeStore = ThemeStore.shared(context)
+    private val colors get() = themeStore.themeState.value.currentTheme.tokens.color
+
+    private val avatarContainer = FrameLayout(context).apply {
+        clipChildren = true
+    }
+
+    private val imageView: ImageView = ImageView(context).apply {
+        scaleType = ImageView.ScaleType.FIT_CENTER
+        visibility = GONE
+    }
+
+    private val textView: TextView = TextView(context).apply {
+        gravity = Gravity.CENTER
+        visibility = GONE
+        setTextColor(colors.textColorPrimary)
+        maxLines = 1
+        ellipsize = android.text.TextUtils.TruncateAt.END
+    }
+
+    private var badgeView: Badge? = null
+
+    private var avatarSize: AvatarSize = AvatarSize.M
+    private var avatarShape: AvatarShape = AvatarShape.Round
+    private var avatarBadge: AvatarBadge = AvatarBadge.None
+
+    private var actualAvatarSizePx: Int = 0
+
+    init {
+        avatarContainer.addView(
+            imageView,
+            LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+        )
+        avatarContainer.addView(
+            textView,
+            LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+        )
+
+        addView(avatarContainer)
+
+        attrs?.let { parseAttributes(it) }
+
+        setSize(avatarSize)
+        applyShape()
+        applyBackgroundColor()
+    }
+
+    private fun parseAttributes(attrs: AttributeSet) {
+        val typedArray: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.AtomicAvatar)
+        
+        try {
+            val sizeOrdinal = typedArray.getInt(R.styleable.AtomicAvatar_avatarSize, AvatarSize.M.ordinal)
+            avatarSize = AvatarSize.values()[sizeOrdinal]
+            
+            val shapeOrdinal = typedArray.getInt(R.styleable.AtomicAvatar_avatarShape, AvatarShape.Round.ordinal)
+            avatarShape = AvatarShape.values()[shapeOrdinal]
+            
+        } finally {
+            typedArray.recycle()
+        }
+    }
+
+    fun setContent(content: AvatarContent) {
+        when (content) {
+            is AvatarContent.URL -> setImageContent(content.url, content.placeImage)
+            is AvatarContent.Text -> setTextContent(content.name)
+            is AvatarContent.Icon -> setIconContent(content.drawable)
+        }
+    }
+
+    fun setSize(size: AvatarSize) {
+        avatarSize = size
+        textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, size.textSizeSp.toFloat())
+
+        applyShape()
+        requestLayout()
+        invalidate()
+    }
+
+    fun setShape(shape: AvatarShape) {
+        avatarShape = shape
+        applyShape()
+    }
+
+    fun setBadge(badge: AvatarBadge) {
+        avatarBadge = badge
+
+        badgeView?.let { removeView(it) }
+        badgeView = null
+
+        if (badge !is AvatarBadge.None) {
+            badgeView = Badge(context).apply {
+                when (badge) {
+                    is AvatarBadge.Dot -> setType(Badge.BadgeType.Dot)
+                    is AvatarBadge.Text -> {
+                        setText(badge.text)
+                        setType(Badge.BadgeType.Text)
+                    }
+
+                    else -> {}
+                }
+            }
+            addView(badgeView)
+        }
+
+        requestLayout()
+    }
+
+    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
+        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
+        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
+        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
+
+        val newAvatarSizePx = when {
+            widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY -> {
+                kotlin.math.min(widthSize, heightSize)
+            }
+
+            widthMode == MeasureSpec.EXACTLY -> {
+                widthSize
+            }
+
+            heightMode == MeasureSpec.EXACTLY -> {
+                heightSize
+            }
+
+            else -> {
+                dp2px(context, avatarSize.sizeDp)
+            }
+        }
+
+        if (actualAvatarSizePx != newAvatarSizePx) {
+            actualAvatarSizePx = newAvatarSizePx
+            applyShape()
+        }
+
+        val exactSpec = MeasureSpec.makeMeasureSpec(actualAvatarSizePx, MeasureSpec.EXACTLY)
+        avatarContainer.measure(exactSpec, exactSpec)
+
+        var containerWidth = actualAvatarSizePx
+        var containerHeight = actualAvatarSizePx
+
+        badgeView?.let { badge ->
+            badge.measure(
+                MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
+                MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
+            )
+            val badgeW = badge.measuredWidth
+            val badgeH = badge.measuredHeight
+
+            val (centerX, centerY) = calculateBadgeCenter(actualAvatarSizePx)
+
+            val badgeLeft = (centerX - badgeW / 2).toInt()
+            val badgeTop = (centerY - badgeH / 2).toInt()
+
+            val badgeActualRight = badgeLeft + badgeW
+            if (badgeActualRight > actualAvatarSizePx) {
+                containerWidth = badgeActualRight
+            }
+
+            val topOverflow = if (badgeTop < 0) -badgeTop else 0
+            containerHeight = actualAvatarSizePx + topOverflow
+        }
+
+        setMeasuredDimension(
+            resolveSize(containerWidth, widthMeasureSpec),
+            resolveSize(containerHeight, heightMeasureSpec)
+        )
+    }
+
+    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
+        val sizePx = actualAvatarSizePx
+        val (centerX, centerY) = calculateBadgeCenter(sizePx)
+
+        var topOffset = 0
+
+        badgeView?.let { badge ->
+            val badgeH = badge.measuredHeight
+            val rawBadgeTop = (centerY - badgeH / 2).toInt()
+            if (rawBadgeTop < 0) {
+                topOffset = -rawBadgeTop
+            }
+        }
+
+        avatarContainer.layout(0, topOffset, sizePx, topOffset + sizePx)
+
+        badgeView?.let { badge ->
+            val badgeW = badge.measuredWidth
+            val badgeH = badge.measuredHeight
+
+            val badgeLeft = (centerX - badgeW / 2).toInt()
+            val rawBadgeTop = (centerY - badgeH / 2).toInt()
+            val badgeTop = rawBadgeTop + topOffset
+
+            badge.layout(badgeLeft, badgeTop, badgeLeft + badgeW, badgeTop + badgeH)
+        }
+    }
+
+    private fun calculateBadgeCenter(avatarSizePx: Int): Pair<Float, Float> {
+        return when (avatarShape) {
+            AvatarShape.Round -> {
+                val radius = avatarSizePx / 2f
+                val extraPadding = dp2px(context, BADGE_EXTRA_PADDING_DP).toFloat()
+                val offset = (radius + extraPadding) * SQRT2_OVER_2
+                (radius + offset) to (radius - offset)
+            }
+
+            AvatarShape.RoundRectangle -> {
+                val borderRadiusPx = dp2px(context, avatarSize.borderRadiusDp).toFloat()
+                if (avatarBadge is AvatarBadge.Dot) {
+                    val offset = borderRadiusPx * (1 - SQRT2_OVER_2)
+                    (avatarSizePx - offset) to offset
+                } else {
+                    avatarSizePx.toFloat() to 0f
+                }
+            }
+
+            AvatarShape.Rectangle -> {
+                avatarSizePx.toFloat() to 0f
+            }
+        }
+    }
+
+    private fun setImageContent(url: String, @DrawableRes placeImage: Int) {
+        imageView.visibility = VISIBLE
+        textView.visibility = GONE
+        ImageLoader.load(context, imageView, url, placeImage)
+    }
+
+    private fun setTextContent(name: String) {
+        imageView.visibility = GONE
+        textView.visibility = VISIBLE
+        textView.text = name
+    }
+
+    private fun setIconContent(drawable: Drawable) {
+        imageView.visibility = VISIBLE
+        textView.visibility = GONE
+        imageView.setImageDrawable(drawable)
+    }
+
+    private fun applyShape() {
+        val sizePx = if (actualAvatarSizePx > 0) {
+            actualAvatarSizePx.toFloat()
+        } else {
+            dp2px(context, avatarSize.sizeDp).toFloat()
+        }
+
+        val clipProvider = when (avatarShape) {
+            AvatarShape.Round -> createRoundClipDrawable(sizePx / 2)
+            AvatarShape.RoundRectangle -> createRoundClipDrawable(
+                dp2px(
+                    context,
+                    avatarSize.borderRadiusDp
+                ).toFloat()
+            )
+            AvatarShape.Rectangle -> null
+        }
+
+        avatarContainer.clipToOutline = clipProvider != null
+        avatarContainer.outlineProvider = clipProvider
+    }
+
+    private fun applyBackgroundColor() {
+        avatarContainer.setBackgroundColor(colors.bgColorAvatar)
+    }
+
+    private fun createRoundClipDrawable(radius: Float): ViewOutlineProvider {
+        return object : ViewOutlineProvider() {
+            override fun getOutline(view: View, outline: Outline) {
+                val actualRadius = when (avatarShape) {
+                    AvatarShape.Round -> view.width / 2f
+                    AvatarShape.RoundRectangle -> dp2px(context, avatarSize.borderRadiusDp).toFloat()
+                    else -> radius
+                }
+                outline.setRoundRect(0, 0, view.width, view.height, actualRadius)
+            }
+        }
+    }
+}
+
+private class Badge @JvmOverloads constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+    companion object {
+        private const val DOT_SIZE_DP = 8f
+        private const val TEXT_HEIGHT_DP = 16f
+        private const val TEXT_HORIZONTAL_PADDING_DP = 5f
+        private const val TEXT_CORNER_RADIUS_DP = 8f
+        private const val TEXT_SIZE_SP = 12f
+    }
+
+    enum class BadgeType {
+        Dot,
+        Text
+    }
+
+    private val themeStore = ThemeStore.shared(context)
+
+    private var backgroundColor: Int = 0
+    private var textColor: Int = 0
+
+    private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        style = Paint.Style.FILL
+    }
+
+    private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        textAlign = Paint.Align.CENTER
+        textSize = TypedValue.applyDimension(
+            TypedValue.COMPLEX_UNIT_SP,
+            TEXT_SIZE_SP,
+            resources.displayMetrics
+        )
+        typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+    }
+
+    private var badgeType: BadgeType = BadgeType.Text
+    private var badgeText: String = ""
+    private val rectF = RectF()
+
+    private var cachedDotSize: Int = 0
+    private var cachedTextHeight: Int = 0
+    private var cachedTextPadding: Int = 0
+    private var cachedCornerRadius: Float = 0f
+
+    init {
+        cachedDotSize = dp2px(context, DOT_SIZE_DP)
+        cachedTextHeight = dp2px(context, TEXT_HEIGHT_DP)
+        cachedTextPadding = dp2px(context, TEXT_HORIZONTAL_PADDING_DP * 2)
+        cachedCornerRadius = dp2px(context, TEXT_CORNER_RADIUS_DP).toFloat()
+
+        updateColors()
+    }
+
+    fun setType(type: BadgeType) {
+        if (badgeType != type) {
+            badgeType = type
+            requestLayout()
+            invalidate()
+        }
+    }
+
+    fun setText(text: String) {
+        if (badgeText != text) {
+            badgeText = text
+            requestLayout()
+            invalidate()
+        }
+    }
+
+    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+        when (badgeType) {
+            BadgeType.Dot -> {
+                setMeasuredDimension(cachedDotSize, cachedDotSize)
+            }
+
+            BadgeType.Text -> {
+                if (badgeText.isEmpty()) {
+                    setMeasuredDimension(0, 0)
+                } else {
+                    val textWidth = textPaint.measureText(badgeText)
+                    val width = (ceil(textWidth) + cachedTextPadding).toInt()
+                    setMeasuredDimension(width, cachedTextHeight)
+                }
+            }
+        }
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        super.onDraw(canvas)
+        when (badgeType) {
+            BadgeType.Dot -> drawDot(canvas)
+            BadgeType.Text -> {
+                if (badgeText.isNotEmpty()) {
+                    drawTextBadge(canvas)
+                }
+            }
+        }
+    }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        updateColors()
+    }
+
+    private fun drawDot(canvas: Canvas) {
+        backgroundPaint.color = backgroundColor
+        val centerX = width / 2f
+        val centerY = height / 2f
+        val radius = width / 2f
+        canvas.drawCircle(centerX, centerY, radius, backgroundPaint)
+    }
+
+    private fun drawTextBadge(canvas: Canvas) {
+        backgroundPaint.color = backgroundColor
+        rectF.set(0f, 0f, width.toFloat(), height.toFloat())
+        canvas.drawRoundRect(rectF, cachedCornerRadius, cachedCornerRadius, backgroundPaint)
+
+        textPaint.color = textColor
+        val centerX = width / 2f
+        val textY = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2
+        canvas.drawText(badgeText, centerX, textY, textPaint)
+    }
+
+    private fun updateColors() {
+        val colors = themeStore.themeState.value.currentTheme.tokens.color
+        backgroundColor = colors.textColorError
+        textColor = colors.textColorButton
+    }
+}

+ 460 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/avatar/README.md

@@ -0,0 +1,460 @@
+# AtomicAvatar 头像组件
+
+AtomicAvatar 是一个高度可定制的 Android 头像组件,基于 Kotlin 设计,支持 **XML 布局配置**和**代码动态设置**两种使用方式。它遵循 AtomicX 设计系统,提供了多种尺寸、形状和徽章样式,适用于用户头像、群组头像、图标展示等多种场景。
+
+## 文件结构
+
+```
+avatar/
+├── AtomicAvatar.kt              # 头像组件核心实现
+└── README.md                    # 本文件
+```
+
+## 快速开始
+
+### 方式一:XML 布局配置(推荐)
+
+直接在 XML 中配置所有属性,无需代码设置:
+
+```xml
+<!-- 用户头像:URL + 圆形 + 中等尺寸 + 红点徽章 -->
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:id="@+id/user_avatar"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    app:avatarSize="m"
+    app:avatarShape="round"
+    app:avatarUrl="https://example.com/avatar.jpg"
+    app:avatarPlaceholder="@drawable/ic_default_avatar"
+    app:badgeType="dot" />
+```
+
+### 方式二:代码动态设置
+
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:id="@+id/avatar"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content" />
+```
+
+在代码中配置:
+
+```kotlin
+val avatar = findViewById<AtomicAvatar>(R.id.avatar)
+
+// 设置头像内容(图片URL)
+avatar.setContent(
+    AtomicAvatar.AvatarContent.URL(
+        url = "https://example.com/avatar.jpg",
+        placeImage = R.drawable.default_avatar
+    )
+)
+
+// 设置尺寸
+avatar.setSize(AtomicAvatar.AvatarSize.L)
+
+// 设置形状
+avatar.setShape(AtomicAvatar.AvatarShape.Round)
+
+// 设置徽章
+avatar.setBadge(AtomicAvatar.AvatarBadge.Dot)
+```
+
+## XML 属性说明
+
+### 可用的 XML 属性
+
+| 属性名 | 类型 | 说明 | 可选值 |
+|--------|------|------|--------|
+| `app:avatarSize` | enum | 头像尺寸 | xxs/xs/s/m(默认)/l/xl/xxl |
+| `app:avatarShape` | enum | 头像形状 | round(默认)/roundRectangle/rectangle |
+| `app:avatarUrl` | string | 图片URL | 任意URL字符串 |
+| `app:avatarPlaceholder` | reference | 占位图 | @drawable/xxx |
+| `app:avatarText` | string | 文本内容 | 任意文本 |
+| `app:avatarIcon` | reference | 图标资源 | @drawable/xxx |
+| `app:badgeType` | enum | 徽章类型 | none(默认)/dot/text |
+| `app:badgeText` | string | 徽章文本 | 任意文本(配合badgeType="text"使用)|
+
+### 内容优先级
+
+当同时设置多个内容属性时,优先级为:**avatarUrl > avatarIcon > avatarText**
+
+### 完整 XML 示例
+
+#### 示例 1:用户头像(URL + 圆形 + 中等尺寸 + 红点)
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:id="@+id/user_avatar"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    app:avatarSize="m"
+    app:avatarShape="round"
+    app:avatarUrl="https://example.com/user_avatar.jpg"
+    app:avatarPlaceholder="@drawable/ic_default_avatar"
+    app:badgeType="dot" />
+```
+
+#### 示例 2:群组头像(文本 + 圆角矩形 + 大尺寸 + 数字徽章)
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:id="@+id/group_avatar"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    app:avatarSize="l"
+    app:avatarShape="roundRectangle"
+    app:avatarText="开发组"
+    app:badgeType="text"
+    app:badgeText="5" />
+```
+
+#### 示例 3:系统图标(图标 + 矩形 + 小尺寸)
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:id="@+id/system_icon"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    app:avatarSize="s"
+    app:avatarShape="rectangle"
+    app:avatarIcon="@drawable/ic_notification" />
+```
+
+#### 示例 4:XXS 超小头像(带数字徽章)
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:id="@+id/mini_avatar"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    app:avatarSize="xxs"
+    app:avatarShape="round"
+    app:avatarText="A"
+    app:badgeType="text"
+    app:badgeText="99+" />
+```
+
+## 代码使用示例
+
+如果需要动态修改头像属性,可以使用代码方式:
+
+### 1. 基础图片头像
+
+```kotlin
+val avatar = findViewById<AtomicAvatar>(R.id.avatar)
+
+avatar.setContent(
+    AtomicAvatar.AvatarContent.URL(
+        url = "https://example.com/avatar.jpg",
+        placeImage = R.drawable.default_avatar
+    )
+)
+avatar.setSize(AtomicAvatar.AvatarSize.L)
+avatar.setShape(AtomicAvatar.AvatarShape.Round)
+```
+
+### 2. 文字头像
+
+适用于没有头像图片时,显示文本内容。
+
+```kotlin
+val avatar = findViewById<AtomicAvatar>(R.id.avatar)
+
+avatar.setContent(
+    AtomicAvatar.AvatarContent.Text(name = "张")
+)
+avatar.setSize(AtomicAvatar.AvatarSize.M)
+avatar.setShape(AtomicAvatar.AvatarShape.Round)
+```
+
+### 3. 图标头像
+
+使用 Drawable 资源作为头像内容。
+
+```kotlin
+val avatar = findViewById<AtomicAvatar>(R.id.avatar)
+
+val drawable = ContextCompat.getDrawable(context, R.drawable.ic_group)
+avatar.setContent(
+    AtomicAvatar.AvatarContent.Icon(drawable = drawable!!)
+)
+avatar.setSize(AtomicAvatar.AvatarSize.XL)
+avatar.setShape(AtomicAvatar.AvatarShape.RoundRectangle)
+```
+
+### 4. 带徽章的头像
+
+显示在线状态、未读消息数等信息。
+
+```kotlin
+val avatar = findViewById<AtomicAvatar>(R.id.avatar)
+
+// 设置头像内容
+avatar.setContent(
+    AtomicAvatar.AvatarContent.URL(
+        url = userAvatarUrl,
+        placeImage = R.drawable.default_avatar
+    )
+)
+
+// 添加红点徽章(在线状态)
+avatar.setBadge(AtomicAvatar.AvatarBadge.Dot)
+
+// 或添加数字徽章(未读消息数)
+avatar.setBadge(AtomicAvatar.AvatarBadge.Text("99+"))
+
+avatar.setSize(AtomicAvatar.AvatarSize.L)
+avatar.setShape(AtomicAvatar.AvatarShape.Round)
+```
+
+### 5. 带点击事件的头像
+
+```kotlin
+val avatar = findViewById<AtomicAvatar>(R.id.avatar)
+
+avatar.setContent(
+    AtomicAvatar.AvatarContent.URL(url = userAvatarUrl, placeImage = R.drawable.default_avatar)
+)
+
+// 设置点击监听器
+avatar.setOnAvatarClickListener {
+    // 跳转到用户详情页
+    navigateToUserProfile(userId)
+}
+```
+
+## 核心 API
+
+### AtomicAvatar.AvatarContent(头像内容)
+
+头像组件支持三种内容类型:
+
+#### 1. URL - 图片头像
+```kotlin
+AtomicAvatar.AvatarContent.URL(
+    url: String,              // 图片URL
+    placeImage: Int           // 占位图资源ID(@DrawableRes)
+)
+```
+
+#### 2. Text - 文字头像
+```kotlin
+AtomicAvatar.AvatarContent.Text(
+    name: String              // 显示的文本内容
+)
+```
+
+#### 3. Icon - 图标头像
+```kotlin
+AtomicAvatar.AvatarContent.Icon(
+    drawable: Drawable        // Drawable 资源
+)
+```
+
+### AtomicAvatar.AvatarSize(头像尺寸)
+
+提供 7 种预定义尺寸规格:
+
+| 尺寸 | 大小 (dp) | 文字大小 (sp) | 圆角半径 (dp) | 使用场景 |
+| :--- | :--- | :--- | :--- | :--- |
+| `XXS` | 16 | 10 | 4 | 超小标签、迷你图标 |
+| `XS` | 24 | 12 | 4 | 小型头像列表、标签 |
+| `S` | 32 | 14 | 4 | 评论区、聊天气泡 |
+| `M` | 40 | 16 | 4 | 会话列表(默认) |
+| `L` | 48 | 18 | 8 | 联系人列表 |
+| `XL` | 64 | 28 | 12 | 个人中心、用户卡片 |
+| `XXL` | 96 | 36 | 12 | 个人主页头部 |
+
+**XML 使用:**
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    app:avatarSize="l" />
+```
+
+**代码使用:**
+```kotlin
+avatar.setSize(AtomicAvatar.AvatarSize.XL)
+```
+
+### AtomicAvatar.AvatarShape(头像形状)
+
+支持 3 种形状样式:
+
+| 形状 | 说明 | 使用场景 |
+| :--- | :--- | :--- |
+| `Round` | 圆形 | 个人头像(最常用) |
+| `RoundRectangle` | 圆角矩形 | 群组头像、品牌Logo |
+| `Rectangle` | 直角矩形 | 封面图、卡片图 |
+
+**XML 使用:**
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    app:avatarShape="roundRectangle" />
+```
+
+**代码使用:**
+```kotlin
+avatar.setShape(AtomicAvatar.AvatarShape.Round)
+```
+
+### AtomicAvatar.AvatarBadge(徽章)
+
+支持 3 种徽章类型:
+
+#### 1. None - 无徽章
+
+**XML 使用:**
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    app:badgeType="none" />
+```
+
+**代码使用:**
+```kotlin
+avatar.setBadge(AtomicAvatar.AvatarBadge.None)
+```
+
+#### 2. Dot - 红点徽章
+
+适用于在线状态、新消息提示。
+
+**XML 使用:**
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    app:badgeType="dot" />
+```
+
+**代码使用:**
+```kotlin
+avatar.setBadge(AtomicAvatar.AvatarBadge.Dot)
+```
+
+#### 3. Text - 文本徽章
+
+适用于未读消息数、等级标识等。
+
+**XML 使用:**
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    app:badgeType="text"
+    app:badgeText="99+" />
+```
+
+**代码使用:**
+```kotlin
+avatar.setBadge(AtomicAvatar.AvatarBadge.Text("99+"))
+```
+
+### 徽章位置规则
+
+徽章会根据头像形状自动计算最佳位置:
+
+- **圆形头像**:徽章位于右上角 45° 位置(对角线方向)
+- **圆角矩形**:
+  - Dot 徽章:位于圆角内切位置
+  - Text 徽章:位于右上角顶点
+- **矩形**:徽章位于右上角顶点
+
+
+## 动态主题 (Dynamic Theming)
+
+`AtomicAvatar` 内置了对 `ThemeStore` 的支持。头像的背景色、文字颜色等会自动响应主题变化(例如切换深色模式),无需手动刷新。
+
+## 布局尺寸规则
+
+`AtomicAvatar` 支持灵活的尺寸设置方式,遵循 Android 标准的测量规范:
+
+### 1. 使用 `wrap_content`(推荐)
+
+由 `avatarSize` 属性决定实际尺寸:
+
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    app:avatarSize="l" />
+<!-- 实际显示: 48dp × 48dp -->
+```
+
+### 2. 明确指定尺寸
+
+直接控制头像大小,忽略 `avatarSize` 属性:
+
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:layout_width="60dp"
+    android:layout_height="60dp"
+    app:avatarSize="m" />
+<!-- 实际显示: 60dp × 60dp,avatarSize 被覆盖 -->
+```
+
+### 3. 自适应父布局
+
+支持 `match_parent` 和 ConstraintLayout 约束:
+
+```xml
+<!-- 填充父布局 -->
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:layout_width="match_parent"
+    android:layout_height="match_parent" />
+
+<!-- ConstraintLayout 约束 -->
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:layout_width="0dp"
+    android:layout_height="0dp"
+    app:layout_constraintWidth_percent="0.2"
+    app:layout_constraintDimensionRatio="1:1" />
+```
+
+### 4. 宽高不一致时
+
+组件会取宽高中的较小值,确保头像保持正方形:
+
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.avatar.AtomicAvatar
+    android:layout_width="80dp"
+    android:layout_height="60dp" />
+<!-- 实际显示: 60dp × 60dp(取较小值)-->
+```
+
+### 优先级规则
+
+1. **明确指定的尺寸** (`layout_width`/`layout_height` 为具体数值) > **avatarSize 属性**
+2. **wrap_content** 时使用 **avatarSize 属性**
+3. 宽高不一致时,取**较小值**保持正方形
+
+## 注意事项
+
+1. **内容优先级**: 当在 XML 中同时设置多个内容属性时,优先级为:`avatarUrl` > `avatarIcon` > `avatarText`。
+
+2. **占位图**: 使用 `avatarUrl` 时,建议设置 `avatarPlaceholder` 作为加载失败时的占位图。
+
+3. **文字内容**: `AtomicAvatar.AvatarContent.Text` 直接显示传入的文本内容,请在外部处理后传入。
+
+4. **徽章文本长度**: 建议徽章文本不超过 3 个字符(如 "99+"),过长文本会影响美观。
+
+5. **Drawable 资源**: 使用 `AtomicAvatar.AvatarContent.Icon` 时,传入的 Drawable 不应为 null。
+
+6. **主题支持**: 头像的背景色和文本颜色会自动适配当前主题(`ThemeStore`)。
+
+7. **保持正方形**: 组件会自动保持正方形比例,如果宽高设置不一致,会取较小值。
+
+## 设计规范
+
+### 尺寸选择建议
+
+- **超小图标**:使用 XXS (16dp)
+- **列表场景**:优先使用 M (40dp) 或 S (32dp)
+- **详情场景**:优先使用 L (48dp) 或 XL (64dp)
+- **个人主页**:使用 XXL (96dp)
+- **小型标签**:使用 XS (24dp)
+
+### 形状选择建议
+
+- **个人用户**:Round(圆形)
+- **群组/品牌**:RoundRectangle(圆角矩形)
+- **封面图**:Rectangle(矩形)
+
+### 徽章使用建议
+
+- **在线状态**:使用 Dot
+- **未读消息**:使用 Text(显示数字)
+- **无提示**:使用 None

+ 579 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/button/AtomicButton.kt

@@ -0,0 +1,579 @@
+package io.trtc.tuikit.atomicx.widget.basicwidget.button
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.PorterDuff
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.GradientDrawable
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.MotionEvent
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.annotation.ColorInt
+import androidx.core.content.withStyledAttributes
+import androidx.core.graphics.drawable.DrawableCompat
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.theme.Theme
+import io.trtc.tuikit.atomicx.theme.ThemeStore
+import io.trtc.tuikit.atomicx.theme.tokens.DesignTokenSet
+import io.trtc.tuikit.atomicx.widget.utils.DisplayUtil
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+enum class ButtonVariant {
+    FILLED,
+    OUTLINED,
+    TEXT
+}
+
+enum class ButtonColorType {
+    PRIMARY,
+    SECONDARY,
+    DANGER
+}
+
+enum class ButtonIconPosition {
+    START,
+    END,
+    NONE
+}
+
+enum class ButtonSize(
+    val heightDp: Float,
+    val minWidthDp: Float,
+    val iconSizeDp: Float,
+) {
+    XS(24f, 48f, 14f),
+    S(32f, 64f, 16f),
+    M(40f, 80f, 20f),
+    L(48f, 96f, 20f);
+
+    fun getHeightPx(context: Context): Int = DisplayUtil.dp2px(context, heightDp)
+    fun getMinWidthPx(context: Context): Int = DisplayUtil.dp2px(context, minWidthDp)
+    fun getIconSizePx(context: Context): Int = DisplayUtil.dp2px(context, iconSizeDp)
+}
+
+class AtomicButton @JvmOverloads constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0,
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+    companion object {
+        private const val ICON_PADDING_DP = 4f
+    }
+
+    private var lastDesignConfig: ButtonDesignConfig? = null
+
+    private val contentContainer: LinearLayout
+    private val iconView: ImageView = ImageView(context).apply {
+        visibility = GONE
+        scaleType = ImageView.ScaleType.CENTER_INSIDE
+    }
+    private val textView: TextView = TextView(context).apply {
+        gravity = Gravity.CENTER
+        includeFontPadding = false
+    }
+
+    var variant: ButtonVariant = ButtonVariant.FILLED
+        set(value) {
+            field = value
+            updateAppearance()
+        }
+
+    var colorType: ButtonColorType = ButtonColorType.PRIMARY
+        set(value) {
+            field = value
+            updateAppearance()
+        }
+
+    var size: ButtonSize = ButtonSize.S
+        set(value) {
+            field = value
+            requestLayout()
+            updateAppearance()
+        }
+
+    var iconDrawable: Drawable? = null
+        set(value) {
+            field = value
+            updateIcon()
+        }
+
+    var iconPosition: ButtonIconPosition = ButtonIconPosition.NONE
+        set(value) {
+            field = value
+            updateIcon()
+        }
+
+    var text: CharSequence
+        get() = textView.text
+        set(value) {
+            textView.text = value
+        }
+
+    var isBold: Boolean = false
+        set(value) {
+            field = value
+            updateAppearance()
+        }
+
+    var customTextSizeSp: Float? = null
+        set(value) {
+            field = value
+            updateAppearance()
+        }
+
+    private var buttonScope: CoroutineScope? = null
+
+    init {
+
+        contentContainer = LinearLayout(context).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER
+            addView(iconView)
+            addView(textView)
+        }
+
+        addView(
+            contentContainer, FrameLayout.LayoutParams(
+                FrameLayout.LayoutParams.WRAP_CONTENT,
+                FrameLayout.LayoutParams.WRAP_CONTENT
+            ).apply {
+                gravity = Gravity.CENTER
+            }
+        )
+
+        isClickable = true
+        isFocusable = true
+
+        if (attrs != null) {
+            context.withStyledAttributes(
+                attrs,
+                R.styleable.AtomicButton,
+                defStyleAttr,
+                0
+            ) {
+                variant = when (getInt(R.styleable.AtomicButton_buttonVariant, 0)) {
+                    1 -> ButtonVariant.OUTLINED
+                    2 -> ButtonVariant.TEXT
+                    else -> ButtonVariant.FILLED
+                }
+
+                colorType = when (getInt(R.styleable.AtomicButton_buttonColorType, 0)) {
+                    1 -> ButtonColorType.SECONDARY
+                    2 -> ButtonColorType.DANGER
+                    else -> ButtonColorType.PRIMARY
+                }
+
+                size = when (getInt(R.styleable.AtomicButton_customButtonSize, 1)) {
+                    0 -> ButtonSize.XS
+                    1 -> ButtonSize.S
+                    2 -> ButtonSize.M
+                    3 -> ButtonSize.L
+                    else -> ButtonSize.S
+                }
+
+                iconPosition = when (getInt(R.styleable.AtomicButton_buttonIconPosition, 2)) {
+                    0 -> ButtonIconPosition.START
+                    1 -> ButtonIconPosition.END
+                    else -> ButtonIconPosition.NONE
+                }
+
+                iconDrawable = getDrawable(R.styleable.AtomicButton_buttonIcon)
+
+                textView.text = getText(R.styleable.AtomicButton_android_text)
+
+                isBold = getBoolean(R.styleable.AtomicButton_textStyle, false)
+
+                if (hasValue(R.styleable.AtomicButton_buttonTextSize)) {
+                    val sizePx = getDimension(R.styleable.AtomicButton_buttonTextSize, 0f)
+                    val scaledDensity = resources.displayMetrics.scaledDensity
+                    customTextSizeSp = sizePx / scaledDensity
+                }
+            }
+        }
+
+        if (isInEditMode) {
+            updateAppearance(Theme.lightTheme(context).tokens)
+        }
+    }
+
+    override fun setEnabled(enabled: Boolean) {
+        super.setEnabled(enabled)
+        textView.isEnabled = enabled
+        iconView.isEnabled = enabled
+        updateAppearance()
+    }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        bindTheme()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        buttonScope?.cancel()
+        buttonScope = null
+    }
+
+    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+        val heightPx = size.getHeightPx(context)
+        val minWidthPx = size.getMinWidthPx(context)
+
+        val finalHeightMeasureSpec = MeasureSpec.makeMeasureSpec(heightPx, MeasureSpec.EXACTLY)
+        super.onMeasure(widthMeasureSpec, finalHeightMeasureSpec)
+        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
+        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
+
+        val finalWidth = when (widthMode) {
+            MeasureSpec.EXACTLY -> {
+                widthSize
+            }
+            MeasureSpec.AT_MOST -> {
+                maxOf(measuredWidth, minWidthPx).coerceAtMost(widthSize)
+            }
+            MeasureSpec.UNSPECIFIED -> {
+                maxOf(measuredWidth, minWidthPx)
+            }
+            else -> measuredWidth
+        }
+        setMeasuredDimension(finalWidth, heightPx)
+    }
+
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        if (!isEnabled) {
+            return false
+        }
+
+        when (event.action) {
+            MotionEvent.ACTION_DOWN -> {
+                isPressed = true
+            }
+            MotionEvent.ACTION_MOVE -> {
+                val isInside = event.x >= 0 && event.x <= width &&
+                        event.y >= 0 && event.y <= height
+                isPressed = isInside
+            }
+            MotionEvent.ACTION_UP -> {
+                if (isPressed) {
+                    isPressed = false
+                    performClick()
+                }
+            }
+            MotionEvent.ACTION_CANCEL -> {
+                isPressed = false
+            }
+        }
+        return true
+    }
+
+    override fun performClick(): Boolean {
+        return super.performClick()
+    }
+
+    override fun setPressed(pressed: Boolean) {
+        val wasPressed = isPressed
+        super.setPressed(pressed)
+
+        if (wasPressed != pressed) {
+            updateAppearance()
+        }
+    }
+
+    private fun bindTheme() {
+        buttonScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+        buttonScope?.launch {
+            ThemeStore.shared(context).themeState.collectLatest {
+                updateAppearance(it.currentTheme.tokens)
+            }
+        }
+    }
+
+    private fun getCurrentTokens(): DesignTokenSet {
+        return ThemeStore.shared(context).themeState.value.currentTheme.tokens
+    }
+
+    private fun updateAppearance(tokens: DesignTokenSet = getCurrentTokens()) {
+        val newConfig = getButtonDesignConfig(tokens)
+
+        textView.setTextColor(newConfig.textColor)
+        textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, newConfig.fontSize)
+        textView.typeface = if (newConfig.isBold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
+
+        if (shouldRecreateBackground(newConfig, lastDesignConfig)) {
+            background = createBackgroundDrawable(tokens, newConfig)
+        } else {
+            updateExistingBackground(newConfig)
+        }
+
+        updateIcon(newConfig.textColor)
+        lastDesignConfig = newConfig
+    }
+
+    private fun shouldRecreateBackground(
+        newConfig: ButtonDesignConfig,
+        oldConfig: ButtonDesignConfig?,
+    ): Boolean {
+        if (oldConfig == null) return true
+
+        if (newConfig.cornerRadiusDp != oldConfig.cornerRadiusDp ||
+            newConfig.borderWidth != oldConfig.borderWidth
+        ) {
+            return true
+        }
+        if ((newConfig.borderWidth == 0f && oldConfig.borderWidth != 0f) ||
+            (newConfig.borderWidth != 0f && oldConfig.borderWidth == 0f)
+        ) {
+            return true
+        }
+
+        return false
+    }
+
+    private fun updateExistingBackground(newConfig: ButtonDesignConfig) {
+        val currentBackground = background
+
+        if (currentBackground is GradientDrawable) {
+            currentBackground.setColor(newConfig.backgroundColor)
+            currentBackground.setStroke(
+                DisplayUtil.dp2px(context, newConfig.borderWidth),
+                newConfig.borderColor
+            )
+        }
+    }
+
+    private fun updateIcon(@ColorInt tintColor: Int = textView.currentTextColor) {
+        val icon = iconDrawable
+        val iconSizeDp = size.iconSizeDp
+        val iconSizePx = DisplayUtil.dp2px(context, iconSizeDp)
+        val iconPadding = DisplayUtil.dp2px(context, ICON_PADDING_DP)
+
+        if (icon == null || iconPosition == ButtonIconPosition.NONE) {
+            iconView.visibility = View.GONE
+            return
+        }
+
+        iconView.visibility = View.VISIBLE
+
+        val wrappedIcon = DrawableCompat.wrap(icon.mutate())
+        DrawableCompat.setTint(wrappedIcon, tintColor)
+        DrawableCompat.setTintMode(wrappedIcon, PorterDuff.Mode.SRC_IN)
+
+        iconView.setImageDrawable(wrappedIcon)
+
+        val iconLayoutParams = iconView.layoutParams as? LinearLayout.LayoutParams
+            ?: LinearLayout.LayoutParams(iconSizePx, iconSizePx)
+
+        iconLayoutParams.width = iconSizePx
+        iconLayoutParams.height = iconSizePx
+
+        when (iconPosition) {
+            ButtonIconPosition.START -> {
+                iconLayoutParams.setMargins(0, 0, iconPadding, 0)
+                contentContainer.removeAllViews()
+                contentContainer.addView(iconView)
+                contentContainer.addView(textView)
+            }
+
+            ButtonIconPosition.END -> {
+                iconLayoutParams.setMargins(iconPadding, 0, 0, 0)
+                contentContainer.removeAllViews()
+                contentContainer.addView(textView)
+                contentContainer.addView(iconView)
+            }
+
+            ButtonIconPosition.NONE -> {
+                iconView.visibility = View.GONE
+            }
+        }
+
+        iconView.layoutParams = iconLayoutParams
+    }
+
+    private data class ButtonDesignConfig(
+        @ColorInt val backgroundColor: Int,
+        @ColorInt val borderColor: Int,
+        @ColorInt val textColor: Int,
+        val borderWidth: Float,
+        val cornerRadiusDp: Float,
+        val fontSize: Float,
+        val isBold: Boolean,
+    )
+
+    private fun getButtonDesignConfig(
+        tokens: DesignTokenSet,
+        forcePressed: Boolean = false
+    ): ButtonDesignConfig {
+        val colors = tokens.color
+        val font = tokens.font
+
+        val isEnabledState = isEnabled
+        val isPressedState = forcePressed || isPressed
+
+        val primaryColorTokens = when (colorType) {
+            ButtonColorType.PRIMARY -> Triple(
+                colors.buttonColorPrimaryDefault,
+                colors.buttonColorPrimaryActive,
+                colors.buttonColorPrimaryDisabled
+            )
+
+            ButtonColorType.SECONDARY -> Triple(
+                colors.buttonColorSecondaryDefault,
+                colors.buttonColorSecondaryActive,
+                colors.buttonColorSecondaryDisabled
+            )
+
+            ButtonColorType.DANGER -> Triple(
+                colors.buttonColorHangupDefault,
+                colors.buttonColorHangupActive,
+                colors.buttonColorHangupDisabled
+            )
+        }
+
+        val textColors = when (colorType) {
+            ButtonColorType.PRIMARY -> Triple(
+                colors.textColorLink,
+                colors.textColorLinkActive,
+                colors.textColorLinkDisabled
+            )
+
+            ButtonColorType.SECONDARY -> Triple(
+                colors.textColorPrimary,
+                colors.textColorSecondary,
+                colors.textColorDisable
+            )
+
+            ButtonColorType.DANGER -> Triple(
+                colors.buttonColorHangupDefault,
+                colors.buttonColorHangupActive,
+                colors.buttonColorHangupDisabled
+            )
+        }
+
+        @ColorInt val defaultPrimaryColor = primaryColorTokens.first
+        @ColorInt val activePrimaryColor = primaryColorTokens.second
+        @ColorInt val disabledPrimaryColor = primaryColorTokens.third
+
+        @ColorInt val defaultTextColor = textColors.first
+        @ColorInt val activeTextColor = textColors.second
+        @ColorInt val disabledTextColor = textColors.third
+
+        @ColorInt var finalBg: Int
+        @ColorInt var finalBorder: Int
+        @ColorInt var finalText: Int
+        val borderWidth: Float
+
+        if (!isEnabledState) {
+            finalText = when (variant) {
+                ButtonVariant.FILLED -> colors.textColorButtonDisabled
+                ButtonVariant.OUTLINED, ButtonVariant.TEXT -> disabledTextColor
+            }
+
+            val primaryDisabledColor = disabledPrimaryColor
+
+            when (variant) {
+                ButtonVariant.FILLED -> {
+                    finalBg = primaryDisabledColor
+                    finalBorder = primaryDisabledColor
+                }
+
+                ButtonVariant.OUTLINED -> {
+                    finalBg = Color.TRANSPARENT
+                    finalBorder = primaryDisabledColor
+                }
+
+                ButtonVariant.TEXT -> {
+                    finalBg = Color.TRANSPARENT
+                    finalBorder = Color.TRANSPARENT
+                }
+            }
+
+            borderWidth = if (variant == ButtonVariant.OUTLINED) 1f else 0f
+        } else {
+            val currentPrimaryColor =
+                if (isPressedState) activePrimaryColor else defaultPrimaryColor
+            val currentTextColor = if (isPressedState) activeTextColor else defaultTextColor
+
+            when (variant) {
+                ButtonVariant.FILLED -> {
+                    finalBg = currentPrimaryColor
+                    finalBorder = currentPrimaryColor
+                    finalText = colors.textColorButton
+                    borderWidth = 0f
+                }
+
+                ButtonVariant.OUTLINED -> {
+                    finalBg = Color.TRANSPARENT
+                    finalBorder = currentPrimaryColor
+                    finalText = when (colorType) {
+                        ButtonColorType.SECONDARY -> {
+                            colors.textColorButton
+                        }
+                        ButtonColorType.PRIMARY,
+                        ButtonColorType.DANGER -> {
+                            currentPrimaryColor
+                        }
+                    }
+                    borderWidth = 1f
+                }
+
+                ButtonVariant.TEXT -> {
+                    finalBg = Color.TRANSPARENT
+                    finalBorder = Color.TRANSPARENT
+                    finalText = currentTextColor
+                    borderWidth = 0f
+                }
+            }
+        }
+
+        val defaultFontSize = when (size) {
+            ButtonSize.XS -> font.bold12.size
+            ButtonSize.S -> font.bold14.size
+            ButtonSize.M -> font.bold16.size
+            ButtonSize.L -> font.bold16.size
+        }
+
+        val finalFontSize = customTextSizeSp ?: defaultFontSize
+
+        val finalCornerRadiusDp = 9999f
+
+        return ButtonDesignConfig(
+            backgroundColor = finalBg,
+            borderColor = finalBorder,
+            textColor = finalText,
+            borderWidth = borderWidth,
+            cornerRadiusDp = finalCornerRadiusDp,
+            fontSize = finalFontSize,
+            isBold = isBold
+        )
+    }
+
+    private fun createBackgroundDrawable(
+        tokens: DesignTokenSet,
+        config: ButtonDesignConfig,
+    ): Drawable {
+        val cornerRadiusPx = DisplayUtil.dp2px(context, config.cornerRadiusDp).toFloat()
+        val borderWidthPx = DisplayUtil.dp2px(context, config.borderWidth)
+
+        val backgroundDrawable = GradientDrawable().apply {
+            shape = GradientDrawable.RECTANGLE
+            cornerRadius = cornerRadiusPx
+            setColor(config.backgroundColor)
+            setStroke(borderWidthPx, config.borderColor)
+        }
+
+        return backgroundDrawable
+    }
+}

+ 178 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/button/README.md

@@ -0,0 +1,178 @@
+# AtomicButton 基础组件
+
+AtomicX Android UIKit 通用按钮组件 `AtomicButton`,支持多种语义变体、尺寸档位和灵活的图文布局,并与主题系统深度集成。
+
+## 文件结构
+
+```
+button/
+├── AtomicButton.kt           # 按钮组件实现(变体、尺寸、布局、主题响应)
+└── README.md                 # 本文件
+```
+
+## 快速开始
+
+### 在 XML 布局中使用
+
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.button.AtomicButton
+    android:id="@+id/btnSubmit"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="提交"
+    app:buttonVariant="filled"
+    app:buttonColorType="primary"
+    app:customButtonSize="m" />
+```
+
+### XML 属性一览
+
+| 属性名 | 类型 | 可选值 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `android:text` | string | - | 按钮文本 |
+| `buttonVariant` | enum | `filled` / `outlined` / `text` | 按钮变体样式 |
+| `buttonColorType` | enum | `primary` / `secondary` / `danger` | 按钮语义颜色 |
+| `customButtonSize` | enum | `xs` / `s` / `m` / `l` | 按钮尺寸档位 |
+| `buttonIconPosition` | enum | `start` / `end` / `none` | 图标位置 |
+| `buttonIcon` | reference | drawable 资源 | 按钮图标 |
+| `textStyle` | boolean | `true` / `false` | 是否加粗 |
+| `buttonTextSize` | dimension | 如 `14sp` | 自定义文字大小 |
+
+### 在代码中配置
+
+```kotlin
+// 1. 创建主操作按钮(默认:FILLED + PRIMARY + S)
+val btnSubmit = findViewById<AtomicButton>(R.id.btnSubmit).apply {
+    variant = ButtonVariant.FILLED
+    colorType = ButtonColorType.PRIMARY
+    size = ButtonSize.M
+}
+
+// 2. 创建次级按钮(描边样式)
+val btnCancel = AtomicButton(context).apply {
+    text = "取消"
+    variant = ButtonVariant.OUTLINED
+    colorType = ButtonColorType.SECONDARY
+    size = ButtonSize.M
+}
+
+// 3. 创建危险操作按钮(带图标)
+val btnDelete = AtomicButton(context).apply {
+    text = "删除"
+    variant = ButtonVariant.FILLED
+    colorType = ButtonColorType.DANGER
+    size = ButtonSize.S
+
+    iconDrawable = ContextCompat.getDrawable(context, R.drawable.ic_delete)
+    iconPosition = ButtonIconPosition.START
+}
+
+// 4. 注册点击事件
+btnSubmit.setOnClickListener {
+    // 处理点击逻辑
+}
+```
+
+## 尺寸优先级说明
+
+`AtomicButton` 支持两种方式控制尺寸,优先级如下:
+
+1. **XML 布局属性优先**:如果在 XML 中显式设置了 `layout_height` 或 `layout_width`(非 `wrap_content`),则以 XML 设置的值为准。
+2. **customButtonSize 属性次之**:当 `layout_height="wrap_content"` 时,组件会根据 `customButtonSize`(`xs` / `s` / `m` / `l`)自动计算高度与最小宽度。
+
+> **建议**:一般情况下将 `layout_height` 设为 `wrap_content`,通过 `customButtonSize` 控制尺寸;如有特殊需求可直接指定固定高度覆盖。
+
+## 主要特性
+
+### 1. 语义变体与视觉样式 (`ButtonVariant`)
+
+- `FILLED` (默认): 实心背景,适合主要操作
+- `OUTLINED`: 描边样式,背景透明,适合次级操作
+- `TEXT`: 纯文本样式,无边框无背景,适合轻量级操作
+
+### 2. 颜色语义 (`ButtonColorType`)
+
+- `PRIMARY` (默认): 主题主色,强调操作
+- `SECONDARY`: 中性配色,用于默认/次要操作
+- `DANGER`: 危险/警示操作(通常为红色)
+
+### 3. 四种尺寸档位 (`ButtonSize` / `customButtonSize`)
+
+自动适配高度、最小宽度、图标大小和字体大小。
+
+| 尺寸 | XML 值 | 高度 (dp) | 最小宽度 (dp) | 图标大小 (dp) | 字体大小 |
+| :--- | :--- | :--- | :--- | :--- | :--- |
+| `XS` | `xs` | 24 | 48 | 14 | 12sp |
+| `S` | `s` | 32 | 64 | 16 | 14sp |
+| `M` | `m` | 40 | 80 | 20 | 16sp |
+| `L` | `l` | 48 | 96 | 20 | 16sp |
+
+### 4. 灵活的图文布局
+
+通过 `buttonIcon` 和 `buttonIconPosition` 属性控制:
+
+- `start`: 左图右文
+- `end`: 左文右图
+- `none`: 仅文本(默认)
+
+> 图标会自动进行着色(`tint`)以匹配当前按钮的文本颜色。
+
+### 5. 主题系统集成
+
+- **自动响应**:`AtomicButton` 内部监听 `ThemeStore` 的状态变化。当应用切换主题(如深色模式)时,按钮会自动更新背景色、边框色、文字颜色和水波纹效果,无需手动刷新。
+- **生命周期管理**:组件自动处理协程作用域,在 View `onDetachedFromWindow` 时取消监听,防止内存泄漏。
+
+### 6. 自动状态管理
+
+组件根据 `isEnabled` 和 `isPressed` 状态自动切换样式:
+
+- **Normal**: 默认状态,使用标准色。
+- **Pressed**: 按下状态,背景变深或显示 Ripple 水波纹。
+- **Disabled**: 禁用状态,自动置灰背景、边框和文字,且不响应点击。
+
+## 样式规范细节
+
+组件内部根据 `ButtonVariant` 和 `ButtonColorType` 组合计算最终样式:
+
+- **FILLED 模式**:
+    - 背景色:填充对应 ColorType 的颜色
+    - 边框:无
+    - 文字:通常为反白 (White)
+- **OUTLINED 模式**:
+    - 背景色:透明
+    - 边框:1dp 实线,颜色跟随 ColorType
+    - 文字:颜色跟随 ColorType
+- **TEXT 模式**:
+    - 背景色:透明
+    - 边框:无
+    - 文字:颜色跟随 ColorType
+
+> 圆角默认根据高度自适应为全圆角(Capsule 样式)。
+
+## API 参考
+
+### 核心属性
+
+| 属性 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `variant` | `ButtonVariant` | 按钮变体样式 |
+| `colorType` | `ButtonColorType` | 按钮语义颜色 |
+| `size` | `ButtonSize` | 按钮尺寸 |
+| `iconDrawable` | `Drawable?` | 图标资源 |
+| `iconPosition` | `ButtonIconPosition` | 图标位置 |
+| `text` | `CharSequence` | 按钮文本 |
+| `isBold` | `Boolean` | 是否加粗 |
+| `customTextSizeSp` | `Float?` | 自定义文字大小(sp) |
+
+### 继承属性
+
+继承自 `FrameLayout`,支持所有标准属性:
+- `isEnabled`
+- `setOnClickListener`
+- ...
+
+## 注意事项
+
+1. **EditMode 支持**:在 Android Studio 预览布局中,组件会尝试加载默认主题以展示大致效果。
+2. **布局参数**:设置 `customButtonSize` 属性会自动调整高度和最小宽度,通常建议将 XML 中的高度设为 `wrap_content`。
+3. **图标着色**:组件会强制接管图标的 `tint` 颜色。如果需要显示图标原色,请确保 `iconDrawable` 为 `null` 并自行处理(或扩展组件)。

+ 242 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/label/AtomicLabel.kt

@@ -0,0 +1,242 @@
+package io.trtc.tuikit.atomicx.widget.basicwidget.label
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.GradientDrawable
+import android.util.AttributeSet
+import android.util.Size
+import android.util.TypedValue
+import android.view.Gravity
+import androidx.annotation.ColorInt
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.content.withStyledAttributes
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.theme.Theme
+import io.trtc.tuikit.atomicx.theme.ThemeStore
+import io.trtc.tuikit.atomicx.theme.tokens.ColorTokens
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+class AtomicLabel @JvmOverloads constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0
+) : AppCompatTextView(context, attrs, defStyleAttr) {
+
+    data class LabelAppearance(
+        @ColorInt val textColor: Int,
+        @ColorInt val backgroundColor: Int,
+        val textSize: Float,
+        val textWeight: Int,
+        val cornerRadius: Float
+    ) {
+        companion object {
+            fun defaultAppearance(theme: Theme): LabelAppearance {
+                val defaultFont = theme.tokens.font.regular14
+                return LabelAppearance(
+                    textColor = theme.tokens.color.textColorPrimary,
+                    backgroundColor = Color.TRANSPARENT,
+                    textSize = defaultFont.size,
+                    textWeight = defaultFont.weight,
+                    cornerRadius = 0f
+                )
+            }
+        }
+    }
+
+    data class IconConfiguration(
+        val drawable: Drawable?,
+        val position: Position = Position.LEFT,
+        val spacing: Float = 4f,
+        val size: Size? = null
+    ) {
+        enum class Position { LEFT, RIGHT }
+    }
+
+    fun interface AppearanceProvider {
+        fun provide(theme: Theme): LabelAppearance
+    }
+
+    var iconConfiguration: IconConfiguration? = null
+        set(value) {
+            field = value
+            applyIconConfiguration()
+        }
+
+    private var appearanceProvider: AppearanceProvider? = null
+    private val themeBackgroundDrawable by lazy { GradientDrawable() }
+    private var isBackgroundManaged = false
+    private var cornerRadiusOverride: Float? = null
+    private var textColorTokenKey: String? = null
+    private var backgroundColorTokenKey: String? = null
+    private var backgroundColorStatic: Int? = null
+    private var viewScope: CoroutineScope? = null
+
+    init {
+        if (gravity == 0) {
+            gravity = Gravity.CENTER_VERTICAL
+        }
+
+        parseAttributes(attrs)
+        applyStaticFallback()
+    }
+
+    fun setAppearanceProvider(provider: AppearanceProvider) {
+        this.appearanceProvider = provider
+        if (isAttachedToWindow) {
+            refreshAppearance()
+        }
+    }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        startThemeObservation()
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        stopThemeObservation()
+    }
+
+    private fun startThemeObservation() {
+        if (viewScope != null) return
+
+        viewScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
+        viewScope?.launch {
+            ThemeStore.shared(context).themeState.collect { state ->
+                applyTheme(state.currentTheme)
+            }
+        }
+    }
+
+    private fun stopThemeObservation() {
+        viewScope?.cancel()
+        viewScope = null
+    }
+
+    private fun refreshAppearance() {
+        val currentTheme = ThemeStore.shared(context).themeState.value.currentTheme
+        applyTheme(currentTheme)
+    }
+
+    private fun applyTheme(theme: Theme) {
+        val provider = appearanceProvider
+
+        if (provider != null) {
+            val appearance = provider.provide(theme)
+            applyAppearance(appearance)
+        } else {
+            applyAtomicProperties(theme)
+        }
+    }
+
+    private fun applyAppearance(appearance: LabelAppearance) {
+        setTextColor(appearance.textColor)
+        setTextSize(TypedValue.COMPLEX_UNIT_SP, appearance.textSize)
+        applyTypeface(appearance.textWeight)
+        updateBackgroundState(appearance.backgroundColor, appearance.cornerRadius)
+    }
+
+    private fun applyAtomicProperties(theme: Theme) {
+        textColorTokenKey?.let { key ->
+            val color = theme.tokens.color[key]
+            if (color != 0) {
+                setTextColor(color)
+            }
+        }
+
+        val bgColor = when {
+            backgroundColorTokenKey != null -> theme.tokens.color[backgroundColorTokenKey!!]
+            backgroundColorStatic != null -> backgroundColorStatic!!
+            else -> null
+        }
+
+        if (bgColor != null) {
+            updateBackgroundState(bgColor, cornerRadiusOverride ?: 0f)
+        }
+    }
+
+    private fun updateBackgroundState(@ColorInt color: Int, radius: Float) {
+        if (background != themeBackgroundDrawable) {
+            background = themeBackgroundDrawable
+            isBackgroundManaged = true
+        }
+
+        themeBackgroundDrawable.setColor(color)
+        themeBackgroundDrawable.cornerRadius = radius
+    }
+
+    private fun applyTypeface(weight: Int) {
+        val targetStyle = if (weight == Typeface.BOLD) Typeface.BOLD else Typeface.NORMAL
+        if (typeface == null || typeface.style != targetStyle) {
+            setTypeface(Typeface.create(Typeface.DEFAULT, targetStyle))
+        }
+    }
+
+    private fun applyStaticFallback() {
+        if (backgroundColorStatic != null && backgroundColorTokenKey == null) {
+            updateBackgroundState(backgroundColorStatic!!, cornerRadiusOverride ?: 0f)
+        }
+    }
+
+    private fun parseAttributes(attrs: AttributeSet?) {
+        textColorTokenKey = ColorTokens.parseColorAttribute(
+            attrs = attrs,
+            attrId = android.R.attr.textColor,
+            attrName = "textColor"
+        )
+
+        context.withStyledAttributes(attrs, R.styleable.AtomicLabel) {
+            if (hasValue(R.styleable.AtomicLabel_labelCornerRadius)) {
+                cornerRadiusOverride = getDimension(R.styleable.AtomicLabel_labelCornerRadius, 0f)
+            }
+
+            if (hasValue(R.styleable.AtomicLabel_labelBackgroundColor)) {
+                val (tokenKey, staticColor) = parseColorTokenAttributeWithFallback(
+                    R.styleable.AtomicLabel_labelBackgroundColor
+                )
+                backgroundColorTokenKey = tokenKey
+                backgroundColorStatic = staticColor
+            }
+        }
+    }
+
+    private fun TypedArray.parseColorTokenAttributeWithFallback(index: Int): Pair<String?, Int?> {
+        val resourceId = getResourceId(index, -1)
+        if (resourceId != -1) {
+            val tokenKey = ColorTokens.getTokenKeyFromColorResId(resourceId)
+            if (tokenKey != null) {
+                return Pair(tokenKey, null)
+            }
+        }
+        return Pair(null, getColor(index, 0))
+    }
+
+    private fun applyIconConfiguration() {
+        val config = iconConfiguration
+
+        if (config?.drawable == null) {
+            setCompoundDrawablesRelative(null, null, null, null)
+            return
+        }
+
+        val drawable = config.drawable.mutate()
+        val width = config.size?.width ?: drawable.intrinsicWidth
+        val height = config.size?.height ?: drawable.intrinsicHeight
+
+        drawable.setBounds(0, 0, width, height)
+        compoundDrawablePadding = config.spacing.toInt()
+
+        if (config.position == IconConfiguration.Position.LEFT) {
+            setCompoundDrawablesRelative(drawable, null, null, null)
+        } else {
+            setCompoundDrawablesRelative(null, null, drawable, null)
+        }
+    }
+}

+ 523 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/label/README.md

@@ -0,0 +1,523 @@
+# AtomicLabel 文本标签组件
+
+AtomicLabel 是一个功能丰富的 Android 文本标签组件,基于 AppCompatTextView 封装,提供了图文混排、主题化、颜色 Token 和自定义外观等特性。它遵循 AtomicX 设计系统,支持动态主题切换和多种样式配置。
+
+## 文件结构
+
+```
+label/
+├── AtomicLabel.kt          # Label 组件核心实现
+└── README.md               # 本文件
+```
+
+## 快速开始
+
+AtomicLabel 支持代码创建和 XML 布局两种使用方式。
+
+### 1. 纯文本标签
+
+最简单的文本标签,无图标,使用默认样式。
+
+```kotlin
+val label = AtomicLabel(context)
+label.text = "这是一个文本标签"
+```
+
+### 2. 带图标的标签
+
+支持在文本左侧或右侧添加图标。
+
+```kotlin
+val label = AtomicLabel(context)
+label.text = "带图标的标签"
+label.iconConfiguration = AtomicLabel.IconConfiguration(
+    drawable = ContextCompat.getDrawable(context, R.drawable.ic_star),
+    position = AtomicLabel.IconConfiguration.Position.LEFT,
+    spacing = 8f,
+    size = Size(20, 20)
+)
+```
+
+### 3. 图标在右侧
+
+适用于导航、展开/收起等场景。
+
+```kotlin
+label.iconConfiguration = AtomicLabel.IconConfiguration(
+    drawable = ContextCompat.getDrawable(context, R.drawable.ic_arrow_right),
+    position = AtomicLabel.IconConfiguration.Position.RIGHT,
+    spacing = 4f
+)
+```
+
+### 4. 自定义外观
+
+通过 AppearanceProvider 自定义颜色、字体、圆角等样式。
+
+```kotlin
+val label = AtomicLabel(context)
+label.text = "自定义样式"
+label.setAppearanceProvider { theme ->
+    AtomicLabel.LabelAppearance(
+        textColor = theme.tokens.color.textColorSecondary,
+        backgroundColor = theme.tokens.color.buttonColorPrimaryDefault,
+        textSize = theme.tokens.font.bold16.size,
+        textWeight = Typeface.BOLD,
+        cornerRadius = 8f
+    )
+}
+```
+
+### 5. 圆角背景
+
+支持设置圆角背景,适用于标签、徽章等场景。
+
+```kotlin
+val label = AtomicLabel(context)
+label.text = "圆角标签"
+label.setAppearanceProvider { theme ->
+    AtomicLabel.LabelAppearance(
+        textColor = Color.WHITE,
+        backgroundColor = theme.tokens.color.buttonColorPrimaryDefault,
+        textSize = 14f,
+        textWeight = Typeface.NORMAL,
+        cornerRadius = 12f
+    )
+}
+label.setPadding(16, 8, 16, 8)
+```
+
+### 6. XML 布局使用(支持颜色 Token)
+
+在 XML 中直接使用 AtomicLabel,支持 `android:textColor` 和自定义属性。
+
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.label.AtomicLabel
+    android:id="@+id/myLabel"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="XML 中的标签"
+    android:textColor="@color/text_color_primary"
+    app:labelBackgroundColor="@color/bg_color_function"
+    app:labelCornerRadius="8dp"
+    android:padding="12dp" />
+```
+
+然后在代码中配置:
+
+```kotlin
+val label = findViewById<AtomicLabel>(R.id.myLabel)
+label.iconConfiguration = AtomicLabel.IconConfiguration(
+    drawable = ContextCompat.getDrawable(this, R.drawable.ic_star),
+    position = AtomicLabel.IconConfiguration.Position.LEFT
+)
+```
+
+## 核心 API
+
+### `AtomicLabel` 类
+
+#### 构造函数
+
+```kotlin
+AtomicLabel(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0)
+```
+
+| 参数名 | 类型 | 说明 | 默认值 |
+| :--- | :--- | :--- | :--- |
+| `context` | Context | 上下文对象 | (必填) |
+| `attrs` | AttributeSet? | XML 属性集 | null |
+| `defStyleAttr` | Int | 默认样式属性 | 0 |
+
+#### 属性
+
+| 属性名 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `text` | CharSequence? | 文本内容(继承自 TextView) |
+| `iconConfiguration` | IconConfiguration? | 图标配置,设置后自动更新 |
+
+#### 方法
+
+| 方法名 | 说明 |
+| :--- | :--- |
+| `setAppearanceProvider(provider: AppearanceProvider)` | 设置外观提供者,会在 View attached 时立即刷新样式 |
+
+#### XML 自定义属性
+
+| 属性名 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `android:textColor` | reference\|color | 文本颜色,支持颜色 Token(如 `@color/text_color_primary`)或静态颜色 |
+| `app:labelBackgroundColor` | reference\|color | 背景颜色,支持颜色 Token 或静态颜色 |
+| `app:labelCornerRadius` | dimension | 圆角半径(如 `8dp`) |
+
+### `LabelAppearance` 数据类
+
+定义 Label 的视觉样式。
+
+| 属性名 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `textColor` | Int | 文本颜色(Color Int) |
+| `backgroundColor` | Int | 背景颜色(Color Int) |
+| `textSize` | Float | 字体大小(单位:SP) |
+| `textWeight` | Int | 字体粗细(Typeface.BOLD 或 Typeface.NORMAL) |
+| `cornerRadius` | Float | 圆角半径(单位:DP) |
+
+#### 默认外观
+
+```kotlin
+LabelAppearance.defaultAppearance(theme: Theme): LabelAppearance
+```
+
+返回基于当前主题的默认外观配置。
+
+### `IconConfiguration` 数据类
+
+定义图标的配置参数。
+
+| 属性名 | 类型 | 说明 | 默认值 |
+| :--- | :--- | :--- | :--- |
+| `drawable` | Drawable? | 图标 Drawable 对象 | (必填) |
+| `position` | Position | 图标位置(LEFT/RIGHT) | LEFT |
+| `spacing` | Float | 图标与文本的间距(单位:px) | 4f |
+| `size` | Size? | 图标尺寸,null 则使用原始尺寸 | null |
+
+#### Position 枚举
+
+| 枚举值 | 说明 |
+| :--- | :--- |
+| `LEFT` | 图标在文本左侧 |
+| `RIGHT` | 图标在文本右侧 |
+
+### `AppearanceProvider` 函数接口
+
+用于根据主题动态提供外观配置。
+
+```kotlin
+fun interface AppearanceProvider {
+    fun provide(theme: Theme): LabelAppearance
+}
+```
+
+## 动态主题 (Dynamic Theming)
+
+`AtomicLabel` 内置了对 `ThemeStore` 的支持,会自动监听主题变化并更新样式:
+
+- **自动刷新**:主题切换时,所有 Label 会自动应用新主题
+- **协程管理**:使用基于 View 的 CoroutineScope 监听主题状态,自动处理生命周期
+- **性能优化**:仅在 View attach 时订阅,detach 时自动取消
+- **颜色 Token**:支持在 XML 中使用颜色 Token,自动跟随主题切换
+
+### 方式 1:使用 AppearanceProvider
+
+```kotlin
+val label = AtomicLabel(context)
+label.text = "主题化标签"
+label.setAppearanceProvider { theme ->
+    AtomicLabel.LabelAppearance(
+        textColor = theme.tokens.color.textColorPrimary,
+        backgroundColor = theme.tokens.color.bgColorDefault,
+        textSize = theme.tokens.font.regular14.size,
+        textWeight = theme.tokens.font.regular14.weight,
+        cornerRadius = 4f
+    )
+}
+```
+
+### 方式 2:使用 XML 颜色 Token
+
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.label.AtomicLabel
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="主题化标签"
+    android:textColor="@color/text_color_primary"
+    app:labelBackgroundColor="@color/bg_color_function"
+    app:labelCornerRadius="4dp" />
+```
+
+当用户切换主题时:
+
+```kotlin
+ThemeStore.shared(context).setTheme(Theme.darkTheme(context))
+```
+
+所有使用主题 Token 或 AppearanceProvider 的 Label 会自动更新样式。
+
+## 使用场景
+
+### 1. 状态标签
+
+显示用户状态、订单状态等信息。
+
+```kotlin
+val statusLabel = AtomicLabel(context)
+statusLabel.text = "已完成"
+statusLabel.setAppearanceProvider { theme ->
+    AtomicLabel.LabelAppearance(
+        textColor = Color.WHITE,
+        backgroundColor = theme.tokens.color.toastColorSuccess,
+        textSize = 12f,
+        textWeight = Typeface.NORMAL,
+        cornerRadius = 4f
+    )
+}
+statusLabel.setPadding(12, 4, 12, 4)
+```
+
+### 2. 导航菜单项
+
+带箭头的导航项。
+
+```kotlin
+val menuItem = AtomicLabel(context)
+menuItem.text = "个人设置"
+menuItem.iconConfiguration = AtomicLabel.IconConfiguration(
+    drawable = ContextCompat.getDrawable(context, R.drawable.ic_arrow_right),
+    position = AtomicLabel.IconConfiguration.Position.RIGHT,
+    spacing = 8f,
+    size = Size(16, 16)
+)
+```
+
+### 3. 标签组
+
+多个标签组合展示。
+
+```kotlin
+val tags = listOf("Android", "Kotlin", "AtomicX")
+val tagContainer = LinearLayout(context).apply {
+    orientation = LinearLayout.HORIZONTAL
+}
+
+tags.forEach { tagText ->
+    val tag = AtomicLabel(context)
+    tag.text = tagText
+    tag.setAppearanceProvider { theme ->
+        AtomicLabel.LabelAppearance(
+            textColor = theme.tokens.color.textColorLink,
+            backgroundColor = theme.tokens.color.bgColorFunction,
+            textSize = 12f,
+            textWeight = Typeface.NORMAL,
+            cornerRadius = 8f
+        )
+    }
+    tag.setPadding(16, 6, 16, 6)
+    tagContainer.addView(tag)
+}
+```
+
+### 4. 信息提示
+
+带图标的信息提示。
+
+```kotlin
+val infoLabel = AtomicLabel(context)
+infoLabel.text = "点击查看详情"
+infoLabel.iconConfiguration = AtomicLabel.IconConfiguration(
+    drawable = ContextCompat.getDrawable(context, R.drawable.ic_info),
+    position = AtomicLabel.IconConfiguration.Position.LEFT,
+    spacing = 4f,
+    size = Size(16, 16)
+)
+```
+
+## 注意事项
+
+1. **Context 选择**:建议传入 Activity Context,避免 Application Context 导致的主题问题。
+
+2. **图标尺寸**:图标尺寸建议不超过字体行高的 1.5 倍,以保持视觉平衡。
+
+3. **间距单位**:`IconConfiguration.spacing` 的单位是 **px(像素)**,不是 dp。如需使用 dp,请使用 `dp2px` 工具方法转换。
+
+```kotlin
+val spacingPx = dp2px(context, 8f)
+```
+
+4. **生命周期管理**:组件内部使用基于 View 的 CoroutineScope 自动处理主题监听的生命周期,无需手动取消订阅。
+
+5. **性能优化**:使用 `CompoundDrawables` 实现图标,性能优于 ImageSpan 方案。
+
+6. **背景绘制**:圆角背景通过 `GradientDrawable` 实现,支持硬件加速,使用 lazy 延迟初始化。
+
+7. **文本更新**:修改 `text` 属性会立即刷新显示,无需手动调用 `invalidate()`。
+
+8. **颜色 Token**:
+   - XML 中引用的颜色资源(如 `@color/text_color_primary`)会自动识别为 Token 并跟随主题切换
+   - 直接使用颜色值(如 `#FF000000`)则为静态颜色,不跟随主题
+   - AppearanceProvider 优先级高于 XML 属性
+
+9. **协程调度**:使用 `Dispatchers.Main.immediate` 确保主题更新在 UI 线程立即执行。
+
+## 设计规范
+
+AtomicLabel 遵循以下设计规范:
+
+- **默认字体**:14sp,Regular(400)
+- **图标间距**:4px(约 2dp)
+- **圆角半径**:0-12dp(根据场景调整)
+- **内边距**:建议 8-16dp(水平)、4-8dp(垂直)
+- **图标尺寸**:12-24dp(根据字体大小调整)
+
+## 常见问题
+
+**Q: 如何动态修改文本内容?**  
+A: 直接设置 `text` 属性即可:
+
+```kotlin
+label.text = "新的文本内容"
+```
+
+**Q: 如何移除图标?**  
+A: 将 `iconConfiguration` 设置为 `null`:
+
+```kotlin
+label.iconConfiguration = null
+```
+
+**Q: 如何实现点击效果?**  
+A: 添加点击监听器并使用不同的 Appearance:
+
+```kotlin
+label.setOnClickListener {
+    label.setAppearanceProvider { theme ->
+        AtomicLabel.LabelAppearance(
+            textColor = Color.WHITE,
+            backgroundColor = theme.tokens.color.buttonColorPrimaryActive,
+            textSize = 14f,
+            textWeight = Typeface.NORMAL,
+            cornerRadius = 4f
+        )
+    }
+}
+```
+
+**Q: 如何禁用主题自动更新?**  
+A: 方式 1 - 使用固定的颜色值代替主题 tokens:
+
+```kotlin
+val label = AtomicLabel(context)
+label.text = "固定样式"
+label.setAppearanceProvider { _ ->
+    AtomicLabel.LabelAppearance(
+        textColor = Color.BLACK,
+        backgroundColor = Color.LTGRAY,
+        textSize = 14f,
+        textWeight = Typeface.NORMAL,
+        cornerRadius = 4f
+    )
+}
+```
+
+方式 2 - XML 中使用静态颜色:
+
+```xml
+<io.trtc.tuikit.atomicx.widget.basicwidget.label.AtomicLabel
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="固定样式"
+    android:textColor="#FF000000"
+    app:labelBackgroundColor="#FFDDDDDD"
+    app:labelCornerRadius="4dp" />
+```
+
+**Q: 图标模糊或变形怎么办?**  
+A: 使用矢量图(SVG)或提供 @2x/@3x 的 PNG 资源,并确保设置了正确的 `size`。
+
+**Q: XML 中设置的颜色没有跟随主题切换?**  
+A: 检查是否使用了颜色 Token 资源(如 `@color/text_color_primary`)而非静态颜色值(如 `#FF000000`)。只有 Token 颜色会跟随主题切换。
+
+**Q: 如何在 XML 中同时支持 Token 和静态颜色?**  
+A: 组件会自动识别:
+- `@color/text_color_primary` → 识别为 Token,跟随主题
+- `#FF000000` 或 `@color/custom_static_color` → 识别为静态颜色,不跟随主题
+
+## 完整示例
+
+### 代码方式
+
+```kotlin
+class ExampleActivity : AppCompatActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        
+        val container = LinearLayout(this).apply {
+            orientation = LinearLayout.VERTICAL
+            setPadding(24, 24, 24, 24)
+        }
+        
+        // 1. 基础标签
+        val basicLabel = AtomicLabel(this)
+        basicLabel.text = "基础标签"
+        container.addView(basicLabel)
+        
+        // 2. 带图标的标签
+        val iconLabel = AtomicLabel(this)
+        iconLabel.text = "带图标"
+        iconLabel.iconConfiguration = AtomicLabel.IconConfiguration(
+            drawable = ContextCompat.getDrawable(this, R.drawable.ic_star),
+            position = AtomicLabel.IconConfiguration.Position.LEFT,
+            spacing = 8f,
+            size = Size(20, 20)
+        )
+        container.addView(iconLabel)
+        
+        // 3. 自定义样式标签
+        val customLabel = AtomicLabel(this)
+        customLabel.text = "自定义"
+        customLabel.setAppearanceProvider { theme ->
+            AtomicLabel.LabelAppearance(
+                textColor = Color.WHITE,
+                backgroundColor = theme.tokens.color.buttonColorPrimaryDefault,
+                textSize = theme.tokens.font.bold16.size,
+                textWeight = Typeface.BOLD,
+                cornerRadius = 8f
+            )
+        }
+        customLabel.setPadding(16, 8, 16, 8)
+        container.addView(customLabel)
+        
+        setContentView(container)
+    }
+}
+```
+
+### XML 方式
+
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:padding="24dp">
+
+    <!-- 基础标签 -->
+    <io.trtc.tuikit.atomicx.widget.basicwidget.label.AtomicLabel
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="基础标签" />
+
+    <!-- 使用颜色 Token(跟随主题) -->
+    <io.trtc.tuikit.atomicx.widget.basicwidget.label.AtomicLabel
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="主题化标签"
+        android:textColor="@color/text_color_primary"
+        app:labelBackgroundColor="@color/bg_color_function"
+        app:labelCornerRadius="8dp"
+        android:padding="12dp" />
+
+    <!-- 使用静态颜色(不跟随主题) -->
+    <io.trtc.tuikit.atomicx.widget.basicwidget.label.AtomicLabel
+        android:id="@+id/staticLabel"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="静态样式标签"
+        android:textColor="#FFFFFFFF"
+        app:labelBackgroundColor="#FF4086FF"
+        app:labelCornerRadius="12dp"
+        android:padding="16dp" />
+
+</LinearLayout>
+```

+ 441 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/popover/AtomicPopover.kt

@@ -0,0 +1,441 @@
+package io.trtc.tuikit.atomicx.widget.basicwidget.popover
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.app.Activity
+import android.app.Dialog
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.ContextThemeWrapper
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.Window
+import android.view.WindowManager
+import android.view.animation.DecelerateInterpolator
+import android.widget.FrameLayout
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.pictureinpicture.PictureInPictureStore
+import io.trtc.tuikit.atomicx.theme.ThemeStore
+import io.trtc.tuikit.atomicx.theme.tokens.DesignTokenSet
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+
+open class AtomicPopover(
+    context: Context,
+    private val panelGravity: PanelGravity = PanelGravity.BOTTOM,
+) : Dialog(
+    context,
+    when (panelGravity) {
+        PanelGravity.BOTTOM -> R.style.dialogStyleFromBottom
+        PanelGravity.CENTER -> R.style.dialogStyleCenter
+    }
+) {
+    private val DIALOG_WIDTH_RATIO = 0.80
+
+    private val dialogScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+    private val rootContainer: FrameLayout
+    private val contentContainer: MaxHeightFrameLayout
+
+    private var themeJob: Job? = null
+    private var panelHeight: PanelHeight = PanelHeight.WrapContent
+    private var isAnimating = false
+    private val showAnimation: Boolean = panelGravity == PanelGravity.BOTTOM
+    private var useTransparentBackground: Boolean = false
+    private var showMask: Boolean = true
+
+    enum class PanelGravity {
+        BOTTOM, CENTER
+    }
+
+    sealed class PanelHeight {
+        object WrapContent : PanelHeight()
+        data class Ratio(val value: Float) : PanelHeight()
+    }
+
+    init {
+        contentContainer = MaxHeightFrameLayout(context).apply {
+            val screenHeight = context.resources.displayMetrics.heightPixels
+            maxHeight = if (panelGravity == PanelGravity.BOTTOM) {
+                (screenHeight * 0.9f).toInt()
+            } else {
+                0
+            }
+
+            layoutParams = when (panelGravity) {
+                PanelGravity.BOTTOM -> FrameLayout.LayoutParams(
+                    ViewGroup.LayoutParams.MATCH_PARENT,
+                    ViewGroup.LayoutParams.WRAP_CONTENT
+                ).apply { gravity = Gravity.BOTTOM }
+
+                PanelGravity.CENTER -> FrameLayout.LayoutParams(
+                    (context.resources.displayMetrics.widthPixels * DIALOG_WIDTH_RATIO).toInt(),
+                    ViewGroup.LayoutParams.WRAP_CONTENT
+                ).apply { gravity = Gravity.CENTER }
+            }
+            isClickable = true
+        }
+
+        rootContainer = FrameLayout(context).apply {
+            layoutParams = when (panelGravity) {
+                PanelGravity.BOTTOM -> ViewGroup.LayoutParams(
+                    ViewGroup.LayoutParams.MATCH_PARENT,
+                    ViewGroup.LayoutParams.MATCH_PARENT
+                )
+
+                PanelGravity.CENTER -> ViewGroup.LayoutParams(
+                    ViewGroup.LayoutParams.WRAP_CONTENT,
+                    ViewGroup.LayoutParams.WRAP_CONTENT
+                )
+            }
+            setOnClickListener {
+                dismiss()
+            }
+            setBackgroundColor(Color.TRANSPARENT)
+            addView(contentContainer)
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        window?.requestFeature(Window.FEATURE_NO_TITLE)
+
+        super.onCreate(savedInstanceState)
+        setContentView(rootContainer)
+
+        window?.apply {
+            setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+            decorView.setBackgroundColor(Color.TRANSPARENT)
+            setWindowAnimations(0)
+            setGravity(panelGravity.toAndroidGravity())
+
+            when (panelGravity) {
+                PanelGravity.BOTTOM -> {
+                    setLayout(
+                        ViewGroup.LayoutParams.MATCH_PARENT,
+                        ViewGroup.LayoutParams.MATCH_PARENT
+                    )
+                }
+
+                PanelGravity.CENTER -> {
+                    setLayout(
+                        ViewGroup.LayoutParams.WRAP_CONTENT,
+                        ViewGroup.LayoutParams.WRAP_CONTENT
+                    )
+                }
+            }
+
+            if (showMask) {
+                addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+            }
+            addFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED)
+        }
+
+        setPanelBackground()
+        setMaskColor()
+    }
+
+
+    override fun onStart() {
+        super.onStart()
+
+        themeJob?.cancel()
+        themeJob = dialogScope.launch {
+            launch {
+                ThemeStore.shared(context).themeState.collectLatest {
+                    setPanelBackground()
+                    setMaskColor()
+                }
+
+            }
+
+            launch {
+                PictureInPictureStore.shared.state.isPictureInPictureMode.collectLatest {
+                    if (it) {
+                        dismiss()
+                    }
+                }
+            }
+
+        }
+    }
+
+    override fun onStop() {
+        themeJob?.cancel()
+        themeJob = null
+        super.onStop()
+    }
+
+    override fun show() {
+        super.show()
+
+        if (showAnimation) {
+            contentContainer.translationY = contentContainer.height.toFloat()
+            contentContainer.visibility = View.INVISIBLE
+
+            contentContainer.post {
+                updatePanelHeight()
+
+                contentContainer.postDelayed({
+                    showWithAnimation()
+                }, 16)
+            }
+        } else {
+            contentContainer.visibility = View.VISIBLE
+        }
+    }
+
+    override fun dismiss() {
+        if (isAnimating) return
+        if (!isActivityValid()) {
+            return
+        }
+        if (showAnimation) {
+            dismissWithAnimation()
+        } else {
+            super.dismiss()
+        }
+    }
+
+    fun setContent(view: View) {
+        (view.parent as? ViewGroup)?.removeView(view)
+        contentContainer.removeAllViews()
+        contentContainer.addView(
+            view,
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            ViewGroup.LayoutParams.WRAP_CONTENT
+        )
+
+        if (isShowing) {
+            contentContainer.post {
+                updatePanelHeight()
+            }
+        }
+    }
+
+    fun setPanelHeight(value: PanelHeight) {
+        this.panelHeight = value
+        if (isShowing) {
+            updatePanelHeight()
+        }
+    }
+
+    fun setTransparentBackground(transparent: Boolean) {
+        useTransparentBackground = transparent
+        if (isShowing) {
+            setPanelBackground()
+        }
+    }
+
+    fun setShowMask(show: Boolean) {
+        showMask = show
+        if (isShowing) {
+            setMaskColor()
+        }
+    }
+
+    private fun showWithAnimation() {
+        contentContainer.visibility = View.VISIBLE
+
+        val startY = contentContainer.height.toFloat()
+        val endY = 0f
+
+        ValueAnimator.ofFloat(startY, endY).apply {
+            duration = 250
+            interpolator = DecelerateInterpolator()
+
+            addUpdateListener { animator ->
+                contentContainer.translationY = animator.animatedValue as Float
+            }
+
+            start()
+        }
+    }
+
+    private fun dismissWithAnimation() {
+        isAnimating = true
+
+        val startY = contentContainer.translationY
+        val endY = contentContainer.height.toFloat()
+
+        ValueAnimator.ofFloat(startY, endY).apply {
+            duration = 200
+            interpolator = DecelerateInterpolator()
+
+            addUpdateListener { animator ->
+                contentContainer.translationY = animator.animatedValue as Float
+            }
+
+            doOnEnd {
+                isAnimating = false
+                if (isActivityValid()) {
+                    super@AtomicPopover.dismiss()
+                }
+            }
+
+            start()
+        }
+    }
+
+    private fun updatePanelHeight() {
+        if (panelGravity == PanelGravity.CENTER) {
+            return
+        }
+
+        val screenHeight = context.resources.displayMetrics.heightPixels
+        val screenWidth = context.resources.displayMetrics.widthPixels
+        val maxHeight = (screenHeight * 0.9f).toInt()
+
+        contentContainer.maxHeight = maxHeight
+
+        val layoutParams = contentContainer.layoutParams as FrameLayout.LayoutParams
+        layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
+
+        when (panelHeight) {
+            is PanelHeight.WrapContent -> {
+                layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
+
+                val widthSpec =
+                    View.MeasureSpec.makeMeasureSpec(screenWidth, View.MeasureSpec.EXACTLY)
+                val heightSpec =
+                    View.MeasureSpec.makeMeasureSpec(maxHeight, View.MeasureSpec.AT_MOST)
+
+                contentContainer.measure(widthSpec, heightSpec)
+            }
+
+            is PanelHeight.Ratio -> {
+                val ratio = (panelHeight as PanelHeight.Ratio).value.coerceIn(0f, 1f)
+                layoutParams.height = (screenHeight * ratio).roundToInt()
+            }
+        }
+
+        contentContainer.requestLayout()
+    }
+
+    private fun getCurrentTokens(context: Context): DesignTokenSet {
+        return ThemeStore.shared(context).themeState.value.currentTheme.tokens
+    }
+
+    private fun setMaskColor() {
+        if (!showMask) {
+            window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+            window?.setDimAmount(0f)
+            return
+        }
+        window?.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+        val maskColor = getCurrentTokens(context).color.bgColorMask
+        val dimAmount = Color.alpha(maskColor) / 255f
+        window?.setDimAmount(dimAmount)
+    }
+
+    private fun setPanelBackground() {
+        if (useTransparentBackground) {
+            contentContainer.background = null
+            return
+        }
+
+        val finalBgColor = getCurrentTokens(context).color.bgColorDialog
+        val bottomCornerRadiusPx = context.resources.getDimension(R.dimen.radius_20)
+        val centerCornerRadiusPx = context.resources.getDimension(R.dimen.radius_12)
+
+        val radii = when (panelGravity) {
+            PanelGravity.BOTTOM -> {
+                floatArrayOf(
+                    bottomCornerRadiusPx, bottomCornerRadiusPx,
+                    bottomCornerRadiusPx, bottomCornerRadiusPx,
+                    0f, 0f,
+                    0f, 0f
+                )
+            }
+
+            PanelGravity.CENTER -> {
+                floatArrayOf(
+                    centerCornerRadiusPx, centerCornerRadiusPx,
+                    centerCornerRadiusPx, centerCornerRadiusPx,
+                    centerCornerRadiusPx, centerCornerRadiusPx,
+                    centerCornerRadiusPx, centerCornerRadiusPx
+                )
+            }
+        }
+
+        val drawable = GradientDrawable().apply {
+            setColor(finalBgColor)
+            cornerRadii = radii
+        }
+        contentContainer.background = drawable
+    }
+
+    private fun PanelGravity.toAndroidGravity(): Int {
+        return when (this) {
+            PanelGravity.BOTTOM -> Gravity.BOTTOM
+            PanelGravity.CENTER -> Gravity.CENTER
+        }
+    }
+
+    private fun isActivityValid(): Boolean {
+        val currentContext = context
+        val currentActivity = if (currentContext is ContextThemeWrapper) {
+            currentContext.baseContext
+        } else {
+            currentContext
+        }
+        if (currentActivity is Activity) {
+            if (currentActivity.isFinishing || currentActivity.isDestroyed) {
+                return false
+            }
+        }
+        return true
+    }
+
+    private class MaxHeightFrameLayout(context: Context) : FrameLayout(context) {
+        var maxHeight: Int = 0
+
+        override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+            var newHeightMeasureSpec = heightMeasureSpec
+
+            if (maxHeight > 0) {
+                val heightSize = MeasureSpec.getSize(heightMeasureSpec)
+                val heightMode = MeasureSpec.getMode(heightMeasureSpec)
+
+                newHeightMeasureSpec = when (heightMode) {
+                    MeasureSpec.EXACTLY -> MeasureSpec.makeMeasureSpec(
+                        minOf(heightSize, maxHeight),
+                        MeasureSpec.EXACTLY
+                    )
+
+                    MeasureSpec.AT_MOST -> MeasureSpec.makeMeasureSpec(
+                        minOf(heightSize, maxHeight),
+                        MeasureSpec.AT_MOST
+                    )
+
+                    else -> MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
+                }
+            }
+
+            super.onMeasure(widthMeasureSpec, newHeightMeasureSpec)
+
+            if (maxHeight > 0 && measuredHeight > maxHeight) {
+                setMeasuredDimension(measuredWidth, maxHeight)
+            }
+        }
+    }
+}
+
+private fun ValueAnimator.doOnEnd(action: () -> Unit) {
+    addListener(object : Animator.AnimatorListener {
+        override fun onAnimationStart(animation: Animator) {}
+        override fun onAnimationEnd(animation: Animator) {
+            action()
+        }
+
+        override fun onAnimationCancel(animation: Animator) {}
+        override fun onAnimationRepeat(animation: Animator) {}
+    })
+}

+ 94 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/popover/README.md

@@ -0,0 +1,94 @@
+# AtomicPopover 弹窗容器
+
+`AtomicPopover` 是 AtomicX Android UIKit 的统一弹窗容器,内部整合了 **底部弹出面板** 与 **居中弹出对话框** 两种形态。开发者只需指定位置(`PanelGravity.BOTTOM` / `PanelGravity.CENTER`)即可获得一致的蒙层、主题与动画体验。
+
+## 文件结构
+
+```
+basicwidget/popover/
+├── AtomicPopover.kt        # 弹窗容器实现(本文档对应文件)
+└── ...                     # 相关工具类
+```
+
+## 快速开始
+
+### 1. 底部弹出(Bottom Sheet)
+
+```kotlin
+val bottomPopover = AtomicPopover(
+    context = context,
+    panelGravity = AtomicPopover.PanelGravity.BOTTOM
+)
+
+bottomPopover.setContent(AudioEffectView(context).apply { init(roomId = "12345") })
+bottomPopover.setPanelHeight(AtomicPopover.PanelHeight.Ratio(0.5f))
+
+bottomPopover.show()
+```
+
+### 2. 居中弹出(Center Dialog)
+
+```kotlin
+val centerPopover = AtomicPopover(
+    context = context,
+    panelGravity = AtomicPopover.PanelGravity.CENTER
+)
+
+val dialogLayout = LayoutInflater.from(context).inflate(R.layout.dialog_logout, null)
+centerPopover.setContent(dialogLayout)
+
+centerPopover.show()
+```
+
+> 两种模式均支持 `setContent` 注入任意自定义 View,`AtomicPopover` 仅负责容器、蒙层、圆角和动画。
+
+## 主要特性
+
+1. **双布局模式**:
+   - `PanelGravity.BOTTOM`:底部滑入,支持高度比设置,常用于操作面板、功能列表。
+   - `PanelGravity.CENTER`:居中弹出,宽度默认占屏幕 80%,适合提示类对话框。
+2. **灵活高度**:
+   - `PanelHeight.WrapContent`:内容自适应,最高不超过屏幕 90%。
+   - `PanelHeight.Ratio(value)`:以屏幕高度比例显示(0~1)。
+3. **主题联动**:自动监听 `ThemeStore`,背景、蒙层、圆角在主题切换时实时更新。
+4. **点击蒙层关闭**:默认点击外层区域即 `dismiss()`。
+5. **动画区分**:底部模式具备滑入/滑出动画,居中模式无位移动画,直接渐变呈现。
+
+## API 概览
+
+| 方法 | 说明 |
+| --- | --- |
+| `setContent(view: View)` | 设置弹窗内部显示的内容 View(会自动移除原父容器) |
+| `setPanelHeight(height: PanelHeight)` | 设置高度策略(仅当 `panelGravity = BOTTOM` 时有效) |
+| `show()` | 展示弹窗,自动应用对应动画与蒙层 |
+| `dismiss()` | 关闭弹窗;底部模式会播放下滑动画 |
+
+### PanelGravity
+
+```kotlin
+enum class PanelGravity {
+    BOTTOM,   // 底部弹出
+    CENTER    // 居中弹出
+}
+```
+
+### PanelHeight
+
+```kotlin
+sealed class PanelHeight {
+    object WrapContent : PanelHeight()               // 自适应内容高度
+    data class Ratio(val value: Float) : PanelHeight() // 按屏幕高度比例 (0.0 ~ 1.0)
+}
+```
+
+## 使用建议
+
+1. **内容布局**:`setContent` 会将子 View 宽度强制为容器宽度;居中模式下宽度为 80% 屏宽,可在子布局中自行设置内边距与圆角。
+2. **高度策略**:
+   - 表单、键盘场景建议 `WrapContent`。
+   - 功能面板/音效面板等强调展示区域可使用 `Ratio(0.5f)` 等固定比例。
+3. **复用 View**:若内容 View 需要复用,请确保在传入前与旧父容器解绑(`setContent` 会自动移除父容器,但仍建议避免状态冲突)。
+4. **主题切换**:无需手动刷新,`AtomicPopover` 会响应 `ThemeStore` 的变更自动更新背景与蒙层。
+
+---
+如需更复杂的按钮 DSL、列表项等能力,可将自定义布局交由 `AtomicPopover` 承载,或结合 `AtomicAlertDialog` 等上层封装一起使用。

+ 183 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/toast/AtomicToast.kt

@@ -0,0 +1,183 @@
+package io.trtc.tuikit.atomicx.widget.basicwidget.toast
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.drawable.GradientDrawable
+import android.os.Handler
+import android.os.Looper
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.annotation.DrawableRes
+import com.tencent.imsdk.v2.V2TIMManager
+import io.trtc.tuikit.atomicx.R
+import io.trtc.tuikit.atomicx.theme.ThemeStore
+import io.trtc.tuikit.atomicx.theme.tokens.DesignTokenSet
+import org.json.JSONObject
+import java.lang.ref.WeakReference
+
+private const val ATOMIC_EVENT_ID = 100011
+private const val FRAMEWORK_NAME = "AtomicXCore"
+
+object AtomicToast {
+
+    enum class Style {
+        TEXT,
+        INFO,
+        HELP,
+        LOADING,
+        SUCCESS,
+        WARNING,
+        ERROR
+    }
+
+    enum class Position {
+        TOP,
+        CENTER,
+        BOTTOM
+    }
+
+    enum class Duration(val value: Int) {
+        SHORT(Toast.LENGTH_SHORT),
+        LONG(Toast.LENGTH_LONG)
+    }
+
+    private var toastRef: WeakReference<Toast>? = null
+    private var layoutRef: WeakReference<View>? = null
+
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private var pendingTask: Runnable? = null
+
+    fun show(
+        context: Context,
+        text: String,
+        style: Style = Style.TEXT,
+        position: Position = Position.CENTER,
+        @DrawableRes customIcon: Int? = null,
+        duration: Duration = Duration.SHORT,
+        code: Int? = null,
+        extensionInfo: Map<String, Any>? = null
+    ) {
+        if (text.isBlank()) return
+
+        pendingTask?.let {
+            mainHandler.removeCallbacks(it)
+            pendingTask = null
+        }
+
+        val appContext = context.applicationContext
+
+        val task = object : Runnable {
+            override fun run() {
+                executeRealShow(appContext, text, style, position, customIcon, duration)
+                if (code != null) {
+                    reportAtomicEvent(code, text, extensionInfo)
+                }
+                if (pendingTask === this) {
+                    pendingTask = null
+                }
+            }
+        }
+        pendingTask = task
+        mainHandler.postDelayed(task, 50)
+    }
+
+    private fun executeRealShow(
+        appContext: Context,
+        text: String,
+        style: Style,
+        position: Position,
+        @DrawableRes customIcon: Int?,
+        duration: Duration,
+    ) {
+        val themeStore = ThemeStore.shared(appContext)
+        val tokens = themeStore.themeState.value.currentTheme.tokens
+
+        var toast = toastRef?.get()
+        var layout = layoutRef?.get()
+
+        if (toast == null || layout == null) {
+            layout = LayoutInflater.from(appContext).inflate(R.layout.layout_atomic_toast, null)
+            toast = Toast(appContext)
+
+            @Suppress("DEPRECATION")
+            toast.view = layout
+
+            layoutRef = WeakReference(layout)
+            toastRef = WeakReference(toast)
+        }
+
+        applyDesignTokens(appContext, layout!!, text, style, customIcon, tokens)
+
+        toast.apply {
+            this.duration = duration.value
+            val gravity = when (position) {
+                Position.TOP -> Gravity.TOP or Gravity.CENTER_HORIZONTAL
+                Position.CENTER -> Gravity.CENTER
+                Position.BOTTOM -> Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+            }
+            val yOffset = appContext.resources.getDimension(R.dimen.spacing_20).toInt()
+            setGravity(gravity, 0, if (position == Position.CENTER) 0 else yOffset)
+            show()
+        }
+    }
+
+    @SuppressLint("SetTextI18n")
+    private fun applyDesignTokens(
+        context: Context,
+        container: View,
+        text: String,
+        style: Style,
+        @DrawableRes customIcon: Int?,
+        tokens: DesignTokenSet
+    ) {
+        val iconView = container.findViewById<ImageView>(R.id.image_icon)
+        val textView = container.findViewById<TextView>(R.id.text_toast)
+
+        val backgroundDrawable = GradientDrawable().apply {
+            shape = GradientDrawable.RECTANGLE
+            cornerRadius = context.resources.getDimension(R.dimen.radius_6)
+            setColor(tokens.color.bgColorOperate)
+        }
+
+        container.background = backgroundDrawable
+
+        textView.text = text
+        textView.setTextColor(tokens.color.textColorPrimary)
+        textView.textSize = tokens.font.regular14.size
+
+        iconView.visibility = View.GONE
+        val iconRes = customIcon ?: resolveIconRes(style)
+        if (iconRes != null) {
+            iconView.visibility = View.VISIBLE
+            iconView.setImageResource(iconRes)
+        }
+    }
+
+    @DrawableRes
+    private fun resolveIconRes(style: Style): Int? {
+        return when (style) {
+            Style.INFO -> R.drawable.ic_atomic_toast_info
+            Style.HELP -> R.drawable.ic_atomic_toast_help
+            Style.SUCCESS -> R.drawable.ic_atomic_toast_success
+            Style.WARNING -> R.drawable.ic_atomic_toast_warning
+            Style.ERROR -> R.drawable.ic_atomic_toast_error
+            Style.LOADING -> R.drawable.ic_atomic_toast_loading
+            else -> null
+        }
+    }
+
+    private fun reportAtomicEvent(code: Int, message: String?, extensionInfo: Map<String, Any>?) {
+        val params = JSONObject().apply {
+            put("event_id", ATOMIC_EVENT_ID)
+            put("event_code", code)
+            put("event_message", message)
+            put("more_message", FRAMEWORK_NAME)
+            put("extension_message", extensionInfo)
+        }.toString()
+        V2TIMManager.getInstance().callExperimentalAPI("reportRoomEngineEvent", params, null)
+    }
+}

+ 237 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/basicwidget/toast/README.md

@@ -0,0 +1,237 @@
+# AtomicToast 轻量级提示组件
+
+AtomicToast 是一个轻量级的 Android Toast 提示组件,基于系统 Toast 封装,提供了丰富的语义化样式和自定义选项。它遵循 AtomicX 设计系统,支持多种位置、样式和图标配置。
+
+## 文件结构
+
+```
+toast/
+├── AtomicToast.kt          # Toast 组件核心实现
+└── README.md               # 本文件
+```
+
+## 快速开始
+
+AtomicToast 采用单例模式设计,通过静态方法直接调用,无需创建实例。
+
+### 1. 基础文本提示
+
+最简单的文本提示,无图标。
+
+```kotlin
+AtomicToast.show(
+    context = this,
+    text = "这是一条提示信息"
+)
+```
+
+### 2. 成功提示
+
+带有成功图标的提示,适用于操作完成场景。
+
+```kotlin
+AtomicToast.show(
+    context = this,
+    text = "操作成功!",
+    style = AtomicToast.Style.SUCCESS
+)
+```
+
+### 3. 警告和错误提示
+
+用于警告或错误场景,提供视觉上的区分。
+
+```kotlin
+// 警告
+AtomicToast.show(
+    context = this,
+    text = "请注意:这是警告信息",
+    style = AtomicToast.Style.WARNING
+)
+
+// 错误
+AtomicToast.show(
+    context = this,
+    text = "操作失败,请重试",
+    style = AtomicToast.Style.ERROR
+)
+```
+
+### 4. 加载提示
+
+用于异步操作时显示加载状态。
+
+```kotlin
+// 显示加载
+AtomicToast.show(
+    context = this,
+    text = "正在加载...",
+    style = AtomicToast.Style.LOADING,
+    position = AtomicToast.Position.CENTER,
+    duration = AtomicToast.Duration.LONG
+)
+```
+
+### 5. 不同位置显示
+
+支持顶部、居中、底部三种位置。
+
+```kotlin
+// 顶部
+AtomicToast.show(
+    context = this,
+    text = "顶部显示的 Toast",
+    position = AtomicToast.Position.TOP
+)
+
+// 居中
+AtomicToast.show(
+    context = this,
+    text = "居中显示的 Toast",
+    position = AtomicToast.Position.CENTER
+)
+
+// 底部
+AtomicToast.show(
+    context = this,
+    text = "底部显示的 Toast",
+    position = AtomicToast.Position.BOTTOM
+)
+```
+
+### 6. 自定义图标
+
+支持传入自定义图标资源。
+
+```kotlin
+AtomicToast.show(
+    context = this,
+    text = "自定义图标示例",
+    style = AtomicToast.Style.TEXT,
+    customIcon = R.drawable.ic_custom_icon
+)
+```
+
+## 核心 API
+
+### `show()` 方法参数
+
+| 参数名 | 类型 | 说明 | 默认值 |
+| :--- | :--- | :--- | :--- |
+| `context` | Context | 上下文(建议使用 Application Context) | (必填) |
+| `text` | String | 显示的文本内容 | (必填) |
+| `style` | Style | 语义化样式(见下方枚举) | Style.TEXT |
+| `position` | Position | 显示位置(见下方枚举) | Position.CENTER |
+| `customIcon` | Int? | 自定义图标资源 ID | null |
+| `duration` | Duration | 显示时长(见下方枚举) | Duration.SHORT |
+
+### 样式枚举 (Style)
+
+| 枚举值 | 说明 | 默认图标 |
+| :--- | :--- | :--- |
+| `TEXT` | 纯文本,无图标 | 无 |
+| `INFO` | 信息提示 | 信息图标 |
+| `HELP` | 帮助提示 | 帮助图标 |
+| `LOADING` | 加载中 | 加载动画图标 |
+| `SUCCESS` | 成功提示 | 成功图标 |
+| `WARNING` | 警告提示 | 警告图标 |
+| `ERROR` | 错误提示 | 错误图标 |
+
+### 位置枚举 (Position)
+
+| 枚举值 | 说明 | 适用场景 |
+| :--- | :--- | :--- |
+| `TOP` | 顶部显示 | 通知类消息 |
+| `CENTER` | 居中显示 | 加载、重要提示 |
+| `BOTTOM` | 底部显示 | 常规反馈 |
+
+> **注意**:Android 11 (API 30+) 对自定义 Toast 的位置做了限制,在部分设备上可能强制显示在底部。
+
+### 时长枚举 (Duration)
+
+| 枚举值 | 说明 | 对应系统值 |
+| :--- | :--- | :--- |
+| `SHORT` | 短时显示 | Toast.LENGTH_SHORT (约 2 秒) |
+| `LONG` | 长时显示 | Toast.LENGTH_LONG (约 3.5 秒) |
+
+
+## 动态主题 (Dynamic Theming)
+
+`AtomicToast` 内置了对 `ThemeStore` 的支持,会自动应用当前主题的配置:
+
+- **背景颜色**:使用 `tokens.color.bgColorOperate`
+- **文本颜色**:使用 `tokens.color.textColorPrimary`
+- **字体样式**:使用 `tokens.font.regular14`
+- **圆角大小**:使用 `@dimen/radius_6`
+
+当应用主题切换时,新创建的 Toast 会自动应用新主题样式。
+
+## 使用场景
+
+### 1. 表单提交反馈
+
+```kotlin
+viewModel.submitForm().observe(this) { result ->
+    when (result) {
+        is Success -> AtomicToast.show(this, "提交成功!", AtomicToast.Style.SUCCESS)
+        is Error -> AtomicToast.show(this, result.message, AtomicToast.Style.ERROR)
+    }
+}
+```
+
+### 2. 网络请求状态
+
+```kotlin
+// 开始请求
+AtomicToast.show(
+    context = this,
+    text = "正在加载数据...",
+    style = AtomicToast.Style.LOADING,
+    duration = AtomicToast.Duration.LONG
+)
+
+// 请求完成
+api.fetchData().onComplete {
+    AtomicToast.show(this, "加载完成", AtomicToast.Style.SUCCESS)
+}
+```
+
+### 3. 复制到剪贴板
+
+```kotlin
+button.setOnClickListener {
+    copyToClipboard(text)
+    AtomicToast.show(this, "已复制到剪贴板", AtomicToast.Style.SUCCESS)
+}
+```
+
+## 注意事项
+
+1. **Context 选择**:建议传入 `Application Context` 以避免内存泄漏,特别是在长时间显示的场景。
+
+2. **自动去重**:组件内部会自动取消上一个 Toast,避免 Toast 队列堆积。
+
+3. **文本验证**:如果传入的文本为空(`isBlank()`),Toast 不会显示。
+
+4. **API 30+ 限制**:Android 11 及以上版本对自定义 Toast 的 `setView()` 方法标记为 deprecated,但对前台应用目前仍然有效。组件内部已添加 `@Suppress("DEPRECATION")` 注解处理。
+
+5. **线程安全**:Toast 必须在主线程调用,如果在子线程使用,需要切换到主线程:
+
+```kotlin
+withContext(Dispatchers.Main) {
+    AtomicToast.show(context, "提示信息")
+}
+```
+
+## 设计规范
+
+AtomicToast 遵循以下设计规范:
+
+- **最大宽度**:340dp
+- **最大行数**:2 行,超出部分显示省略号
+- **内边距**:水平 16dp,垂直 8dp
+- **最小高度**:40dp
+- **图标尺寸**:16dp × 16dp
+- **图标间距**:图标与文字间距 4dp
+- **圆角大小**:6dp
+- **字体大小**:14sp

+ 337 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/utils/BlurUtils.kt

@@ -0,0 +1,337 @@
+package io.trtc.tuikit.atomicx.widget.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.os.Build
+import android.renderscript.Allocation
+import android.renderscript.Element
+import android.renderscript.RenderScript
+import android.renderscript.ScriptIntrinsicBlur
+import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+object BlurUtils {
+    private const val SAMPLING = 10f
+
+    @JvmStatic
+    fun blur(context: Context, pool: BitmapPool, toTransform: Bitmap, radius: Int): Bitmap {
+        val width = toTransform.width
+        val height = toTransform.height
+        val scaleWidth = (width / SAMPLING).toInt()
+        val scaleHeight = (height / SAMPLING).toInt()
+
+        val bitmap = pool.get(scaleWidth, scaleHeight, Bitmap.Config.ARGB_8888)
+        bitmap.density = toTransform.density
+
+        val canvas = Canvas(bitmap)
+        canvas.scale(1 / SAMPLING, 1 / SAMPLING)
+        val paint = Paint()
+        paint.flags = Paint.FILTER_BITMAP_FLAG
+        canvas.drawBitmap(toTransform, 0f, 0f, paint)
+
+        var rs: RenderScript? = null
+        var input: Allocation? = null
+        var output: Allocation? = null
+        var blur: ScriptIntrinsicBlur? = null
+
+        try {
+            rs = RenderScript.create(context)
+            rs.messageHandler = RenderScript.RSMessageHandler()
+            input = Allocation.createFromBitmap(
+                rs, bitmap,
+                Allocation.MipmapControl.MIPMAP_NONE,
+                Allocation.USAGE_SCRIPT
+            )
+            output = Allocation.createTyped(rs, input.type)
+            blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
+            blur.setRadius(radius.toFloat())
+            blur.setInput(input)
+            blur.forEach(output)
+            output.copyTo(bitmap)
+        } catch (e: Exception) {
+            e.printStackTrace()
+            fastBlur(bitmap, radius, true)
+        } finally {
+            rs?.let {
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+                    RenderScript.releaseAllContexts()
+                } else {
+                    @Suppress("DEPRECATION")
+                    it.destroy()
+                }
+            }
+            input?.destroy()
+            output?.destroy()
+            blur?.destroy()
+        }
+
+        return bitmap
+    }
+
+    @JvmStatic
+    fun blur(context: Context, bitmap: Bitmap, radius: Int): Bitmap {
+        var rs: RenderScript? = null
+        var input: Allocation? = null
+        var output: Allocation? = null
+        var blur: ScriptIntrinsicBlur? = null
+
+        try {
+            rs = RenderScript.create(context)
+            rs.messageHandler = RenderScript.RSMessageHandler()
+            input = Allocation.createFromBitmap(
+                rs, bitmap,
+                Allocation.MipmapControl.MIPMAP_NONE,
+                Allocation.USAGE_SCRIPT
+            )
+            output = Allocation.createTyped(rs, input.type)
+            blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
+            blur.setRadius(radius.toFloat())
+            blur.setInput(input)
+            blur.forEach(output)
+            output.copyTo(bitmap)
+        } catch (e: Exception) {
+            e.printStackTrace()
+            fastBlur(bitmap, radius, true)
+        } finally {
+            rs?.let {
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+                    RenderScript.releaseAllContexts()
+                } else {
+                    @Suppress("DEPRECATION")
+                    it.destroy()
+                }
+            }
+            input?.destroy()
+            output?.destroy()
+            blur?.destroy()
+        }
+
+        return bitmap
+    }
+
+    @JvmStatic
+    fun fastBlur(sentBitmap: Bitmap, radius: Int, canReuseInBitmap: Boolean): Bitmap {
+        val bitmap = if (canReuseInBitmap) {
+            sentBitmap
+        } else {
+            sentBitmap.copy(sentBitmap.config!!, true)
+        }
+
+        if (radius < 1) {
+            return sentBitmap
+        }
+
+        val w = bitmap.width
+        val h = bitmap.height
+        val pix = IntArray(w * h)
+        bitmap.getPixels(pix, 0, w, 0, 0, w, h)
+
+        val wm = w - 1
+        val hm = h - 1
+        val wh = w * h
+        val div = radius + radius + 1
+
+        val r = IntArray(wh)
+        val g = IntArray(wh)
+        val b = IntArray(wh)
+
+        val vmin = IntArray(max(w, h))
+
+        var divsum = (div + 1) shr 1
+        divsum *= divsum
+        val dv = IntArray(256 * divsum)
+        for (i in 0 until 256 * divsum) {
+            dv[i] = i / divsum
+        }
+
+        var yi = 0
+        var yw = yi
+
+        val stack = Array(div) { IntArray(3) }
+        val r1 = radius + 1
+        var routsum: Int
+        var goutsum: Int
+        var boutsum: Int
+        var rinsum: Int
+        var ginsum: Int
+        var binsum: Int
+
+        for (y in 0 until h) {
+            var rsum = 0
+            var gsum = 0
+            var bsum = 0
+            routsum = 0
+            goutsum = 0
+            boutsum = 0
+            rinsum = 0
+            ginsum = 0
+            binsum = 0
+
+            for (i in -radius..radius) {
+                val p = pix[yi + min(wm, max(i, 0))]
+                val sir = stack[i + radius]
+                sir[0] = (p and 0xff0000) shr 16
+                sir[1] = (p and 0x00ff00) shr 8
+                sir[2] = p and 0x0000ff
+                val rbs = r1 - abs(i)
+                rsum += sir[0] * rbs
+                gsum += sir[1] * rbs
+                bsum += sir[2] * rbs
+                if (i > 0) {
+                    rinsum += sir[0]
+                    ginsum += sir[1]
+                    binsum += sir[2]
+                } else {
+                    routsum += sir[0]
+                    goutsum += sir[1]
+                    boutsum += sir[2]
+                }
+            }
+
+            var stackpointer = radius
+            var x = 0
+            while (x < w) {
+                r[yi] = dv[rsum]
+                g[yi] = dv[gsum]
+                b[yi] = dv[bsum]
+
+                rsum -= routsum
+                gsum -= goutsum
+                bsum -= boutsum
+
+                val stackstart = stackpointer - radius + div
+                var sir = stack[stackstart % div]
+
+                routsum -= sir[0]
+                goutsum -= sir[1]
+                boutsum -= sir[2]
+
+                if (y == 0) {
+                    vmin[x] = min(x + radius + 1, wm)
+                }
+                val p = pix[yw + vmin[x]]
+
+                sir[0] = (p and 0xff0000) shr 16
+                sir[1] = (p and 0x00ff00) shr 8
+                sir[2] = p and 0x0000ff
+
+                rinsum += sir[0]
+                ginsum += sir[1]
+                binsum += sir[2]
+
+                rsum += rinsum
+                gsum += ginsum
+                bsum += binsum
+
+                stackpointer = (stackpointer + 1) % div
+                sir = stack[stackpointer % div]
+
+                routsum += sir[0]
+                goutsum += sir[1]
+                boutsum += sir[2]
+
+                rinsum -= sir[0]
+                ginsum -= sir[1]
+                binsum -= sir[2]
+
+                yi++
+                x++
+            }
+            yw += w
+        }
+
+        for (x in 0 until w) {
+            var rsum = 0
+            var gsum = 0
+            var bsum = 0
+            routsum = 0
+            goutsum = 0
+            boutsum = 0
+            rinsum = 0
+            ginsum = 0
+            binsum = 0
+            var yp = -radius * w
+
+            for (i in -radius..radius) {
+                yi = max(0, yp) + x
+                val sir = stack[i + radius]
+                sir[0] = r[yi]
+                sir[1] = g[yi]
+                sir[2] = b[yi]
+                val rbs = r1 - abs(i)
+                rsum += r[yi] * rbs
+                gsum += g[yi] * rbs
+                bsum += b[yi] * rbs
+                if (i > 0) {
+                    rinsum += sir[0]
+                    ginsum += sir[1]
+                    binsum += sir[2]
+                } else {
+                    routsum += sir[0]
+                    goutsum += sir[1]
+                    boutsum += sir[2]
+                }
+                if (i < hm) {
+                    yp += w
+                }
+            }
+
+            yi = x
+            var stackpointer = radius
+            for (y in 0 until h) {
+                pix[yi] = (pix[yi] and -0x1000000.toInt()) or
+                        (dv[rsum] shl 16) or
+                        (dv[gsum] shl 8) or
+                        dv[bsum]
+
+                rsum -= routsum
+                gsum -= goutsum
+                bsum -= boutsum
+
+                val stackstart = stackpointer - radius + div
+                var sir = stack[stackstart % div]
+
+                routsum -= sir[0]
+                goutsum -= sir[1]
+                boutsum -= sir[2]
+
+                if (x == 0) {
+                    vmin[y] = min(y + r1, hm) * w
+                }
+                val p = x + vmin[y]
+
+                sir[0] = r[p]
+                sir[1] = g[p]
+                sir[2] = b[p]
+
+                rinsum += sir[0]
+                ginsum += sir[1]
+                binsum += sir[2]
+
+                rsum += rinsum
+                gsum += ginsum
+                bsum += binsum
+
+                stackpointer = (stackpointer + 1) % div
+                sir = stack[stackpointer]
+
+                routsum += sir[0]
+                goutsum += sir[1]
+                boutsum += sir[2]
+
+                rinsum -= sir[0]
+                ginsum -= sir[1]
+                binsum -= sir[2]
+
+                yi += w
+            }
+        }
+
+        bitmap.setPixels(pix, 0, w, 0, 0, w, h)
+        return bitmap
+    }
+}

+ 22 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/utils/DisplayUtil.kt

@@ -0,0 +1,22 @@
+package io.trtc.tuikit.atomicx.widget.utils
+
+import android.content.Context
+import androidx.annotation.FloatRange
+import androidx.annotation.IntRange
+
+
+object DisplayUtil {
+
+    @JvmStatic
+    @IntRange(from = 0)
+    fun dp2px(context: Context, @FloatRange(from = 0.0) dpValue: Float): Int {
+        val scale = context.resources.displayMetrics.density
+        return (dpValue * scale + 0.5f).toInt()
+    }
+
+    @JvmStatic
+    fun px2dp(context: Context, pxValue: Float): Int {
+        val scale = context.resources.displayMetrics.density
+        return (pxValue / scale + 0.5f).toInt()
+    }
+}

+ 185 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/utils/ImageLoader.kt

@@ -0,0 +1,185 @@
+package io.trtc.tuikit.atomicx.widget.utils
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.RenderEffect
+import android.graphics.Shader
+import android.os.Build
+import android.util.TypedValue
+import android.widget.ImageView
+import androidx.annotation.DrawableRes
+import androidx.annotation.RawRes
+import com.bumptech.glide.Glide
+import com.bumptech.glide.RequestBuilder
+import com.bumptech.glide.load.MultiTransformation
+import com.bumptech.glide.load.Transformation
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
+import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
+import com.bumptech.glide.load.resource.bitmap.CenterCrop
+import com.bumptech.glide.load.resource.bitmap.RoundedCorners
+import com.bumptech.glide.request.RequestOptions
+import java.security.MessageDigest
+
+object ImageLoader {
+
+    @JvmStatic
+    fun load(context: Context, target: ImageView?, source: Any?, @DrawableRes placeImage: Int) {
+        val imageOptions = ImageOptions.Builder()
+            .setPlaceImage(placeImage)
+            .build()
+        load(context, target, source, imageOptions)
+    }
+
+    @JvmStatic
+    fun load(context: Context, target: ImageView?, source: Any?, config: ImageOptions) {
+        if (target == null) {
+            return
+        }
+
+        if (source == null) {
+            val image = if (config.placeImage != 0) config.placeImage else config.errorImage
+            target.setImageResource(image)
+            return
+        }
+
+        loadImageView(context, target, source, config)
+    }
+
+    @JvmStatic
+    fun loadGif(context: Context, target: ImageView?, @RawRes @DrawableRes resourceId: Int) {
+        if (target == null) {
+            return
+        }
+        Glide.with(context.applicationContext)
+            .asGif()
+            .load(resourceId)
+            .into(target)
+    }
+
+    @JvmStatic
+    fun transformBitmap(
+        context: Context,
+        source: Any?,
+        width: Int,
+        height: Int,
+        options: ImageOptions
+    ): Bitmap? {
+        if (source == null) {
+            return null
+        }
+
+        val builder = Glide.with(context.applicationContext)
+            .asBitmap()
+            .load(source)
+
+        setBuilderOptions(context.applicationContext, builder, options)
+
+        return try {
+            builder.submit(width, height).get()
+        } catch (e: Exception) {
+            e.printStackTrace()
+            null
+        }
+    }
+
+    @JvmStatic
+    fun clear(context: Context, target: ImageView?) {
+        if (target != null) {
+            Glide.with(context.applicationContext).clear(target)
+        }
+    }
+
+    private fun loadImageView(context: Context, target: ImageView, source: Any, config: ImageOptions) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+            if (context is Activity) {
+                if (context.isFinishing || context.isDestroyed) {
+                    return
+                }
+            }
+        }
+
+        val builder = if (config.isGif) {
+            Glide.with(context.applicationContext).asGif().load(source)
+        } else {
+            Glide.with(context.applicationContext).load(source)
+        }
+
+        setBuilderOptions(context.applicationContext, builder, config)
+        builder.into(target)
+        setRenderEffect(target, config)
+    }
+
+    private fun setRenderEffect(target: ImageView, config: ImageOptions) {
+        val level = config.blurEffect
+        if (level > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            target.setRenderEffect(
+                RenderEffect.createBlurEffect(level, level, Shader.TileMode.MIRROR)
+            )
+        }
+    }
+
+    private fun setBuilderOptions(context: Context, builder: RequestBuilder<*>, config: ImageOptions) {
+        val options = RequestOptions()
+
+        val transformations = mutableListOf<Transformation<Bitmap>>()
+        transformations.add(CenterCrop())
+
+        if (config.roundRadius > 0) {
+            transformations.add(RoundedCorners(dpToPx(context, config.roundRadius)))
+        }
+
+        if (config.blurEffect > 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+            transformations.add(BlurTransformation(context, config.blurEffect))
+        }
+
+        options.transform(MultiTransformation(transformations))
+
+        if (config.placeImage != 0) {
+            options.placeholder(config.placeImage)
+        }
+
+        if (config.errorImage != 0) {
+            options.error(config.errorImage)
+        }
+
+        options.diskCacheStrategy(
+            if (config.skipDiskCache) DiskCacheStrategy.NONE else DiskCacheStrategy.ALL
+        )
+        options.skipMemoryCache(config.skipMemoryCache)
+
+        builder.apply(options)
+    }
+
+    private fun dpToPx(context: Context, dp: Int): Int {
+        return TypedValue.applyDimension(
+            TypedValue.COMPLEX_UNIT_DIP,
+            dp.toFloat(),
+            context.resources.displayMetrics
+        ).toInt()
+    }
+
+    class BlurTransformation(
+        context: Context,
+        level: Float
+    ) : BitmapTransformation() {
+
+        private val radius: Float = level * 0.25f
+        private val contextRef = java.lang.ref.WeakReference(context)
+
+        override fun updateDiskCacheKey(messageDigest: MessageDigest) {
+            messageDigest.update("blur:$radius".toByteArray())
+        }
+
+        override fun transform(
+            pool: BitmapPool,
+            toTransform: Bitmap,
+            outWidth: Int,
+            outHeight: Int
+        ): Bitmap {
+            val context = contextRef.get() ?: return toTransform
+            return BlurUtils.blur(context, pool, toTransform, radius.toInt())
+        }
+    }
+}

+ 69 - 0
frame/atomic_x/src/main/java/io/trtc/tuikit/atomicx/widget/utils/ImageOptions.kt

@@ -0,0 +1,69 @@
+package io.trtc.tuikit.atomicx.widget.utils
+
+import androidx.annotation.DrawableRes
+
+data class ImageOptions(
+    @DrawableRes val placeImage: Int = 0,
+    @DrawableRes val errorImage: Int = 0,
+    val roundRadius: Int = 0,
+    val isGif: Boolean = false,
+    val skipMemoryCache: Boolean = false,
+    val skipDiskCache: Boolean = false,
+    val blurEffect: Float = 0f
+) {
+    class Builder {
+        private var placeImage: Int = 0
+        private var errorImage: Int = 0
+        private var roundRadius: Int = 0
+        private var isGif: Boolean = false
+        private var skipMemoryCache: Boolean = false
+        private var skipDiskCache: Boolean = false
+        private var blurEffect: Float = 0f
+
+        fun setPlaceImage(@DrawableRes placeImage: Int) = apply {
+            this.placeImage = placeImage
+        }
+
+        fun setErrorImage(@DrawableRes errorImage: Int) = apply {
+            this.errorImage = errorImage
+        }
+
+        fun setRoundRadius(roundRadius: Int) = apply {
+            this.roundRadius = roundRadius
+        }
+
+        fun asGif(isGif: Boolean) = apply {
+            this.isGif = isGif
+        }
+
+        fun setSkipMemoryCache(skip: Boolean) = apply {
+            this.skipMemoryCache = skip
+        }
+
+        fun setSkipDiskCache(skip: Boolean) = apply {
+            this.skipDiskCache = skip
+        }
+
+        fun setBlurEffect(level: Float) = apply {
+            this.blurEffect = level
+        }
+
+        fun build() = ImageOptions(
+            placeImage = placeImage,
+            errorImage = errorImage,
+            roundRadius = roundRadius,
+            isGif = isGif,
+            skipMemoryCache = skipMemoryCache,
+            skipDiskCache = skipDiskCache,
+            blurEffect = blurEffect
+        )
+    }
+
+    companion object {
+        @JvmStatic
+        fun default() = ImageOptions()
+
+        @JvmStatic
+        fun withPlaceholder(@DrawableRes placeImage: Int) = ImageOptions(placeImage = placeImage)
+    }
+}

+ 5 - 0
frame/atomic_x/src/main/res-advance-setting/color/switch_thumb_bg.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android" tools:ignore="MissingDefaultResource">
+    <item android:color="@color/white" android:state_checked="false"/>
+    <item android:color="@color/white" android:state_checked="true"/>
+</selector>

+ 5 - 0
frame/atomic_x/src/main/res-advance-setting/color/switch_track_bg.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android" tools:ignore="MissingDefaultResource">
+    <item android:color="@color/advance_setting_switch_track_unchecked" android:state_checked="false"/>
+    <item android:color="@color/advance_setting_switch_track_checked" android:state_checked="true"/>
+</selector>

+ 5 - 0
frame/atomic_x/src/main/res-advance-setting/drawable/bg_advance_setting_button.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+    <solid android:color="@color/advance_setting_panel_bg" />
+</shape>

+ 6 - 0
frame/atomic_x/src/main/res-advance-setting/drawable/bg_advance_setting_panel.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="@color/advance_setting_panel_bg" />
+    <corners android:radius="20dp" />
+</shape>

+ 15 - 0
frame/atomic_x/src/main/res-advance-setting/drawable/switch_thumb.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_checked="true">
+        <shape android:shape="oval">
+            <solid android:color="@color/switch_thumb_bg" />
+            <size android:width="24dp" android:height="24dp" />
+        </shape>
+    </item>
+    <item>
+        <shape android:shape="oval">
+            <solid android:color="@color/switch_thumb_bg" />
+            <size android:width="24dp" android:height="24dp" />
+        </shape>
+    </item>
+</selector> 

+ 17 - 0
frame/atomic_x/src/main/res-advance-setting/drawable/switch_track.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_checked="true">
+        <shape android:shape="rectangle">
+            <solid android:color="@color/switch_track_bg" />
+            <corners android:radius="12dp" />
+            <size android:width="36dp" android:height="24dp" />
+        </shape>
+    </item>
+    <item>
+        <shape android:shape="rectangle">
+            <solid android:color="@color/switch_track_bg" />
+            <corners android:radius="12dp" />
+            <size android:width="36dp" android:height="24dp" />
+        </shape>
+    </item>
+</selector> 

+ 16 - 0
frame/atomic_x/src/main/res-advance-setting/layout/advance_setting_button.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:background="@drawable/bg_advance_setting_button"
+    android:orientation="vertical">
+
+    <TextView
+        android:layout_width="50dp"
+        android:layout_height="50dp"
+        android:gravity="center"
+        android:text="@string/advance_setting_button_text"
+        android:textColor="@color/advance_setting_text_color"
+        android:textSize="14sp"
+        android:textStyle="bold" />
+</LinearLayout>

+ 53 - 0
frame/atomic_x/src/main/res-advance-setting/layout/advance_setting_panel.xml

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="280dp"
+    android:layout_height="300dp"
+    android:background="@drawable/bg_advance_setting_panel"
+    android:orientation="vertical"
+    android:padding="16dp">
+
+    <TextView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="16dp"
+        android:gravity="center"
+        android:text="@string/advance_setting"
+        android:textColor="@color/advance_setting_text_color"
+        android:textSize="16sp"
+        android:textStyle="bold" />
+
+    <ScrollView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical">
+
+            <include
+                android:id="@+id/item_hevc"
+                layout="@layout/item_advance_setting" />
+
+            <include
+                android:id="@+id/item_ultimate_video"
+                layout="@layout/item_advance_setting" />
+
+            <include
+                android:id="@+id/item_b_frame"
+                layout="@layout/item_advance_setting" />
+
+            <include
+                android:id="@+id/item_qos_control"
+                layout="@layout/item_advance_setting" />
+
+            <include
+                android:id="@+id/item_dual_encode"
+                layout="@layout/item_advance_setting" />
+
+            <include
+                android:id="@+id/item_network_env"
+                layout="@layout/item_advance_setting" />
+        </LinearLayout>
+    </ScrollView>
+</LinearLayout> 

+ 24 - 0
frame/atomic_x/src/main/res-advance-setting/layout/item_advance_setting.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="horizontal"
+    android:padding="10dp">
+
+    <TextView
+        android:id="@+id/tv_description"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:textColor="@color/advance_setting_text_color"
+        android:textSize="15sp" />
+
+    <Switch
+        android:id="@+id/sw_option"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:thumb="@drawable/switch_thumb"
+        android:track="@drawable/switch_track"
+        tools:ignore="UseSwitchCompatOrMaterialXml" />
+</LinearLayout> 

+ 8 - 0
frame/atomic_x/src/main/res-advance-setting/values/colors.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="advance_setting_panel_bg">#660F1014</color>
+    <color name="advance_setting_button_bg">#2B6AD6</color>
+    <color name="advance_setting_text_color">#D5E0F2</color>
+    <color name="advance_setting_switch_track_unchecked">#A5A9B0</color>
+    <color name="advance_setting_switch_track_checked">#2B6AD6</color>
+</resources>

+ 14 - 0
frame/atomic_x/src/main/res-advance-setting/values/strings.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <string name="advance_setting">音视频测试</string>
+    <string name="hevc_encode">HEVC 编码</string>
+    <string name="supreme_quality">至臻画质</string>
+    <string name="b_frame">B 帧</string>
+    <string name="qos_control">QOS 调控分辨率</string>
+    <string name="switch_environment">测试环境</string>
+    <string name="dual_encode">双路编码</string>
+    <string name="custom_video_capture">自定义视频采集</string>
+    <string name="custom_audio_capture">自定义音频采集</string>
+    <string name="advance_setting_button_text">Debug</string>
+</resources>

+ 6 - 0
frame/atomic_x/src/main/res-advance-setting/values/themes.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.AdvanceSettingExtension" parent="Theme.AppCompat.Light.NoActionBar">
+
+    </style>
+</resources> 

BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_check_box_group_selected.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_group_select_disable.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_hangup_loading.gif


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_add_user_black.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_audio_input.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_audio_route_picker.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_avatar.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_back.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_blur_background_accept.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_blur_disable.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_blur_enable.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_camera_disable.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_camera_enable.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_dialing.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_dialing_pressed.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_float.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_float_button.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_handsfree_disable.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_handsfree_enable.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_hangup.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_hangup_pressed.png


BIN
frame/atomic_x/src/main/res-callview/drawable-xxhdpi/callview_ic_loading.gif


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff