diff --git a/KIWI_DECOUPLING_GUIDE.md b/KIWI_DECOUPLING_GUIDE.md
new file mode 100644
index 0000000..716f786
--- /dev/null
+++ b/KIWI_DECOUPLING_GUIDE.md
@@ -0,0 +1,153 @@
+# Kiwi SDK 解耦使用指南
+
+## 概述
+
+经过重构,Kiwi SDK的初始化已经从RTMP推流和播放器中完全解耦,现在采用独立的初始化管理器。
+
+## 解耦后的架构
+
+```
+KiwiInitializer (独立初始化管理器)
+├── 负责Kiwi SDK的生命周期管理
+├── 提供初始化状态查询
+└── 与业务逻辑完全解耦
+
+RtmpPusher (纯粹的推流器)
+├── startPush() - 普通RTMP推流
+└── startPushWithKiwi() - 支持Kiwi转换的推流
+
+RtmpPlayer (纯粹的播放器)
+├── prepareAsync() - 普通RTMP播放
+└── prepareAsyncWithKiwi() - 支持Kiwi转换的播放
+```
+
+## 使用方法
+
+### 1. 在Application中初始化Kiwi SDK
+
+```kotlin
+class MainApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+
+ // 初始化Kiwi SDK(异步,不阻塞主线程)
+ val appKey = "your_kiwi_app_key"
+ KiwiInitializer.initialize(appKey) { success ->
+ if (success) {
+ Log.d("App", "Kiwi SDK 初始化成功")
+ } else {
+ Log.e("App", "Kiwi SDK 初始化失败")
+ }
+ }
+ }
+
+ override fun onTerminate() {
+ super.onTerminate()
+ // 释放Kiwi资源
+ KiwiInitializer.release()
+ }
+}
+```
+
+### 2. 普通RTMP推流(不使用Kiwi)
+
+```kotlin
+// 创建推流器
+val pusher = RtmpPusher(openGlView, context, listener)
+
+// 开始预览
+pusher.startPreview()
+
+// 开始推流(直接使用RTMP URL)
+pusher.startPush("rtmp://your-server.com/live/stream123")
+```
+
+### 3. 使用Kiwi转换的推流
+
+```kotlin
+// 创建推流器
+val pusher = RtmpPusher(openGlView, context, listener)
+
+// 开始预览
+pusher.startPreview()
+
+// 检查Kiwi是否已初始化
+if (KiwiInitializer.isInitialized()) {
+ // 使用Kiwi转换推流
+ pusher.startPushWithKiwi(
+ baseUrl = "rtmp://fallback-server.com/live/stream123",
+ rsName = "your_rs_name",
+ streamKey = "optional_stream_key"
+ )
+} else {
+ // 回退到普通推流
+ pusher.startPush("rtmp://fallback-server.com/live/stream123")
+}
+```
+
+### 4. 普通RTMP播放(不使用Kiwi)
+
+```kotlin
+// 创建播放器
+val player = RtmpPlayer(context, listener)
+
+// 设置播放视图
+player.setSurface(surface)
+
+// 开始播放(直接使用RTMP URL)
+player.prepareAsync("rtmp://your-server.com/live/stream123")
+```
+
+### 5. 使用Kiwi转换的播放
+
+```kotlin
+// 创建播放器
+val player = RtmpPlayer(context, listener)
+
+// 设置播放视图
+player.setSurface(surface)
+
+// 检查Kiwi是否已初始化
+if (KiwiInitializer.isInitialized()) {
+ // 使用Kiwi转换播放
+ player.prepareAsyncWithKiwi(
+ baseUrl = "rtmp://fallback-server.com/live/stream123",
+ rsName = "your_rs_name"
+ )
+} else {
+ // 回退到普通播放
+ player.prepareAsync("rtmp://fallback-server.com/live/stream123")
+}
+```
+
+### 6. 检查Kiwi初始化状态
+
+```kotlin
+// 检查初始化状态
+val isReady = KiwiInitializer.isInitialized()
+val isInProgress = KiwiInitializer.isInitializing()
+val statusText = KiwiInitializer.getStatusText()
+
+Log.d("Kiwi", "状态: $statusText, 已初始化: $isReady, 初始化中: $isInProgress")
+```
+
+## 解耦的优势
+
+1. **职责分离**: Kiwi初始化与推流/播放逻辑完全分离
+2. **灵活配置**: 可以独立控制是否使用Kiwi转换
+3. **容错性好**: Kiwi初始化失败不影响基本的推流/播放功能
+4. **生命周期清晰**: 在Application级别管理Kiwi SDK生命周期
+5. **易于测试**: 可以独立测试Kiwi功能和推流/播放功能
+
+## 错误处理
+
+- 如果Kiwi SDK未初始化,调用`startPushWithKiwi()`或`prepareAsyncWithKiwi()`会返回错误
+- 如果Kiwi转换失败,会自动回退到传入的基础URL
+- 所有错误都会通过相应的监听器回调通知
+
+## 注意事项
+
+1. Kiwi SDK只需要在Application中初始化一次
+2. 整个应用生命周期中,Kiwi状态会保持
+3. 应用退出时记得调用`KiwiInitializer.release()`释放资源
+4. 推流和播放可以独立选择是否使用Kiwi转换
diff --git a/README.md b/README.md
index 67d0750..791f007 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,643 @@
-# SellyCloudSDK_Android_demo
+SellyCloudSDK
+SellyRTC Android SDK 接入文档
+
+本文档介绍如何在 Android 中使用 SellyRTC 快速集成一对一或多人音视频通话功能,包括:
+- 基本接入
+- 音视频控制
+- 数据处理(如美颜)
+- 事件回调
+- 通话统计
+- Token 生成与更新机制
+
+---
+
+## 目录
+1. 准备工作
+2. 快速开始
+ - 创建引擎
+ - 设置本地/远端画面
+ - 配置视频参数
+ - 加入频道
+ - 结束通话
+3. 常用功能
+ - 开关本地音视频
+ - 切换摄像头
+ - 静音远端音视频
+ - 音频输出控制(扬声器 / 听筒)
+ - 发送自定义消息
+ - 美颜开关
+4. 视频帧处理(美颜等)
+5. 事件回调 (InteractiveRtcEngineEventHandler)
+6. 通话统计
+7. Token 过期机制
+8. 常见问题
+
+---
+
+# 1. 准备工作
+
+## 1.1 集成 SellyCloudSDK
+
+或如果目前是通过本地 AAR 集成(demo 方式):
+
+```gradle
+dependencies {
+ implementation files("libs/sellycloudsdk-release.aar")
+}
+```
+
+> 注意:如果你的业务侧还依赖 WebRTC、ijkplayer、美颜等第三方库,请保持与 SDK Demo 中的依赖版本一致。
+
+## 1.2 必要权限
+
+在 `AndroidManifest.xml` 中声明音视频必需权限:
+
+```xml
+
+
+```
+
+在 Android 6.0+ 设备上,运行时还需要动态申请权限,示例见后文(Demo 中的 `requiredPermissions` + `ActivityResultContracts.RequestMultiplePermissions` 已经实现)。
+
+## 1.3 获取 AppId / Secret / Token
+
+从 SellyCloud 控制台获取:
+
+- `signaling_app_id`
+- `signaling_secret`(用于服务端生成 Token)
+- 或直接配置一个测试用的 `signaling_token`
+
+在 Demo 中,这些值通常配置在 `res/values/strings.xml`:
+
+```xml
+your-app-id
+your-secret
+
+```
+
+> 生产环境建议:
+> 不要在 App 里写 secret,而是在你们自己的业务服务器上生成 Token,App 只向服务器请求 Token。
+
+---
+
+# 2. 快速开始
+
+以下示例基于 Demo 中的 `InteractiveLiveActivity`,展示最小接入流程。
+
+## 2.1 创建引擎 InteractiveRtcEngine
+
+在 `Activity` 中创建并配置 RTC 引擎:
+
+```kotlin
+private var rtcEngine: InteractiveRtcEngine? = null
+private var beautyRenderer: FURenderer? = null
+private var fuFrameInterceptor: FuVideoFrameInterceptor? = null
+@Volatile private var isFrontCamera = true
+@Volatile private var beautyEnabled: Boolean = true
+
+private fun initRtcEngine() {
+ val appId = getString(R.string.signaling_app_id)
+ val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() }
+
+ // 可选:初始化美颜
+ beautyRenderer = FURenderer(this).also { it.setup() }
+ fuFrameInterceptor = beautyRenderer?.let {
+ FuVideoFrameInterceptor(it).apply {
+ setFrontCamera(isFrontCamera)
+ setEnabled(beautyEnabled)
+ }
+ }
+
+ rtcEngine = InteractiveRtcEngine.create(
+ InteractiveRtcEngineConfig(
+ context = applicationContext,
+ appId = appId,
+ defaultToken = token
+ )
+ ).apply {
+ // 设置回调
+ setEventHandler(rtcEventHandler)
+
+ // 角色:主播/观众,Demo 里默认主播(BROADCASTER)
+ setClientRole(InteractiveRtcEngine.ClientRole.BROADCASTER)
+
+ // 配置视频参数(可选,见下一节)
+ setVideoEncoderConfiguration(
+ InteractiveVideoEncoderConfig(
+ width = 640,
+ height = 480,
+ fps = 20,
+ minBitrateKbps = 150,
+ maxBitrateKbps = 350
+ )
+ )
+
+ // 默认走扬声器
+ setDefaultAudioRoutetoSpeakerphone(true)
+
+ // 视频采集前拦截(用于美颜等)
+ setCaptureVideoFrameInterceptor { frame ->
+ if (!beautyEnabled) return@setCaptureVideoFrameInterceptor frame
+ fuFrameInterceptor?.process(frame) ?: frame
+ }
+ }
+}
+```
+
+生命周期注意:
+在 `onDestroy` 中记得 `leaveChannel()` 并销毁引擎,避免内存泄漏:
+
+```kotlin
+override fun onDestroy() {
+ super.onDestroy()
+ rtcEngine?.setCaptureVideoFrameInterceptor(null)
+ leaveChannel()
+ InteractiveRtcEngine.destroy(rtcEngine)
+ rtcEngine = null
+ // 释放 renderer / 美颜资源...
+}
+```
+
+## 2.2 设置本地 & 远端画面
+
+SellyRTC 使用 `InteractiveVideoCanvas + SurfaceViewRenderer` 来承载视频画面。
+
+### 初始化本地与远端渲染 View
+
+```kotlin
+private var localRenderer: SurfaceViewRenderer? = null
+private val remoteRendererMap = mutableMapOf()
+
+private fun createRenderer(): SurfaceViewRenderer =
+ SurfaceViewRenderer(this).apply {
+ setZOrderMediaOverlay(false)
+ }
+
+private fun setupVideoSlots() {
+ // 本地 slot
+ if (localRenderer == null) {
+ localRenderer = createRenderer()
+ }
+ localRenderer?.let { renderer ->
+ // Demo 中使用自定义的 VideoReportLayout 来承载
+ binding.flLocal.attachRenderer(renderer)
+ }
+
+ // 远端 slot 见 Demo 中的 remoteSlots / ensureRemoteRenderer
+}
+```
+
+### 绑定本地视频
+
+在加入频道前/时,设置本地视频 canvas:
+
+```kotlin
+val renderer = localRenderer ?: createRenderer().also { localRenderer = it }
+rtcEngine?.setupLocalVideo(InteractiveVideoCanvas(renderer, localUserId))
+```
+
+### 绑定远端视频
+
+在 `onUserJoined` 或业务逻辑中,为某个 `userId` 分配一个远端窗口:
+
+```kotlin
+private fun ensureRemoteRenderer(userId: String): SurfaceViewRenderer {
+ return remoteRendererMap[userId] ?: createRenderer().also { renderer ->
+ remoteRendererMap[userId] = renderer
+ rtcEngine?.setupRemoteVideo(InteractiveVideoCanvas(renderer, userId))
+ }
+}
+```
+
+> 多人会议:为不同的 `userId` 分配不同的 View / slot,即可实现多路画面显示。
+
+## 2.3 配置视频参数(可选)
+
+视频编码参数需要在加入频道前配置:
+
+```kotlin
+rtcEngine?.setVideoEncoderConfiguration(
+ InteractiveVideoEncoderConfig(
+ width = 640,
+ height = 480,
+ fps = 20,
+ minBitrateKbps = 150,
+ maxBitrateKbps = 350
+ )
+)
+// 不设置则使用 SDK 默认配置
+```
+
+## 2.4 加入频道 / 发起通话
+
+### 1)准备 CallType 等入会参数
+
+```kotlin
+val options = InteractiveChannelMediaOptions(
+ callType = if (isP2P) CallType.ONE_TO_ONE else CallType.GROUP
+)
+```
+
+其中:
+
+- `CallType.ONE_TO_ONE`:一对一视频通话
+- `CallType.GROUP`:多人会议 / 互动直播
+
+### 2)生成 Token
+
+Demo 中的策略(简化):
+
+```kotlin
+private val defaultTokenTtlSeconds = InteractiveCallConfig.DEFAULT_TOKEN_TTL_SECONDS
+
+private fun buildToken(appId: String, callId: String, userId: String): TokenBundle? {
+ val manualToken = getString(R.string.signaling_token).takeIf { it.isNotBlank() }
+ if (manualToken != null) {
+ return TokenBundle(
+ token = manualToken,
+ expiresAtSec = parseExprTime(manualToken),
+ secret = null
+ )
+ }
+
+ val secret = getString(R.string.signaling_secret)
+ if (secret.isBlank()) {
+ Toast.makeText(
+ this,
+ "请在 strings.xml 配置 signaling_secret 用于生成 token,或直接填写 signaling_token",
+ Toast.LENGTH_LONG
+ ).show()
+ return null
+ }
+
+ return try {
+ val generated = TokenGenerator.generate(
+ appId = appId,
+ userId = userId,
+ callId = callId,
+ secret = secret,
+ ttlSeconds = defaultTokenTtlSeconds
+ )
+ TokenBundle(
+ token = generated.token,
+ expiresAtSec = generated.expiresAtSec,
+ secret = secret
+ )
+ } catch (t: Throwable) {
+ Toast.makeText(this, "生成 token 失败: ${t.message}", Toast.LENGTH_LONG).show()
+ null
+ }
+}
+```
+
+> 生产环境建议:
+> 将 `TokenGenerator` 放在你的业务服务器,客户端只请求业务服务器获取 Token。
+
+### 3)调用 joinChannel
+
+```kotlin
+rtcEngine?.joinChannel(
+ token = request.token,
+ callId = request.callId,
+ userId = request.userId,
+ options = request.options, // CallType 等
+ tokenSecret = request.tokenSecret, // 可为空
+ tokenExpiresAtSec = request.tokenExpiresAtSec,
+ tokenTtlSeconds = request.tokenTtlSeconds
+)
+```
+
+成功后,会回调:
+
+```kotlin
+override fun onJoinChannelSuccess(channel: String, userId: String, code: Int) {
+ // 已成功加入频道,可更新 UI 状态
+}
+```
+
+## 2.5 结束通话
+
+业务结束通话时调用:
+
+```kotlin
+private fun leaveChannel() {
+ rtcEngine?.leaveChannel()
+ resetUiAfterLeave() // 清 UI、清理 renderer 等
+}
+```
+
+SDK 会通过:
+
+```kotlin
+override fun onLeaveChannel(durationSeconds: Int) {
+ // 通话结束时长(秒)
+}
+```
+
+通知已经离开频道。
+
+---
+
+# 3. 常用功能
+
+以下示例同样来自 Demo,可直接复用。
+
+## 3.1 开/关本地视频
+
+```kotlin
+private var isLocalVideoEnabled = true
+private var isLocalPreviewEnabled = true
+
+binding.btnToggleCamera.setOnClickListener {
+ isLocalVideoEnabled = !isLocalVideoEnabled
+ rtcEngine?.enableLocalVideo(isLocalVideoEnabled)
+ isLocalPreviewEnabled = isLocalVideoEnabled
+ updateControlButtons()
+}
+```
+
+## 3.2 开/关本地音频采集
+
+```kotlin
+private var isLocalAudioEnabled = true
+
+binding.btnToggleMic.setOnClickListener {
+ isLocalAudioEnabled = !isLocalAudioEnabled
+ rtcEngine?.enableLocalAudio(isLocalAudioEnabled)
+ updateControlButtons()
+}
+```
+
+## 3.3 切换前后摄像头
+
+```kotlin
+binding.btnSwitchCamera.setOnClickListener {
+ isFrontCamera = !isFrontCamera
+ fuFrameInterceptor?.setFrontCamera(isFrontCamera)
+ rtcEngine?.switchCamera()
+}
+```
+
+## 3.4 静音远端音视频
+
+按用户静音远端音频 / 视频:
+
+```kotlin
+private fun muteRemoteUserAudio(targetUserId: String, muted: Boolean) {
+ rtcEngine?.muteRemoteAudioStream(targetUserId, muted)
+}
+
+private fun muteRemoteUserVideo(targetUserId: String, muted: Boolean) {
+ rtcEngine?.muteRemoteVideoStream(targetUserId, muted)
+}
+```
+
+## 3.5 控制音频输出(扬声器 / 听筒)
+
+```kotlin
+private var isSpeakerOn = true
+
+binding.btnToggleAudioRoute.setOnClickListener {
+ isSpeakerOn = !isSpeakerOn
+ rtcEngine?.setDefaultAudioRoutetoSpeakerphone(isSpeakerOn)
+ updateControlButtons()
+}
+```
+
+## 3.6 发送自定义消息
+
+```kotlin
+binding.btnSendMessage.setOnClickListener {
+ val text = binding.etMessage.text?.toString()?.trim().orEmpty()
+ if (text.isEmpty()) {
+ Toast.makeText(this, "请输入消息内容", Toast.LENGTH_SHORT).show()
+ } else if (currentCallId == null) {
+ Toast.makeText(this, "请先加入频道", Toast.LENGTH_SHORT).show()
+ } else {
+ rtcEngine?.sendMessage(text) { error ->
+ runOnUiThread {
+ if (error != null) {
+ Toast.makeText(this, "发送失败: ${error.message}", Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(this, "已发送", Toast.LENGTH_SHORT).show()
+ binding.etMessage.text?.clear()
+ binding.tvMessageLog.text = "我: $text"
+ }
+ }
+ }
+ }
+}
+```
+
+收到消息的回调见后文 `onMessageReceived`。
+
+## 3.7 美颜开关
+
+```kotlin
+binding.btnToggleBeauty.setOnClickListener {
+ beautyEnabled = !beautyEnabled
+ fuFrameInterceptor?.setEnabled(beautyEnabled)
+ updateControlButtons()
+}
+```
+
+---
+
+# 4. 视频帧处理(美颜等)
+
+SellyRTC 提供视频采集前拦截接口,可以在推流前做美颜、滤镜等处理。
+
+在创建引擎后设置:
+
+```kotlin
+rtcEngine?.setCaptureVideoFrameInterceptor { frame ->
+ if (!beautyEnabled) return@setCaptureVideoFrameInterceptor frame
+ fuFrameInterceptor?.process(frame) ?: frame
+}
+```
+
+其中 `FuVideoFrameInterceptor` 内部使用 `FURenderer` 做实际美颜处理。
+
+> 你也可以替换为自己的处理逻辑:
+> - 对 `frame` 做 GPU 或 CPU 处理
+> - 返回处理后的帧给 SDK 继续编码和发送
+
+---
+
+# 5. 事件回调 (InteractiveRtcEngineEventHandler)
+
+实现 `InteractiveRtcEngineEventHandler`,监听通话过程中发生的事件:
+
+```kotlin
+private val rtcEventHandler = object : InteractiveRtcEngineEventHandler {
+
+ override fun onJoinChannelSuccess(channel: String, userId: String, code: Int) { ... }
+
+ override fun onLeaveChannel(durationSeconds: Int) { ... }
+
+ override fun onUserJoined(userId: String, code: Int) { ... }
+
+ override fun onUserLeave(userId: String, code: Int) { ... }
+
+ override fun onConnectionStateChanged(
+ state: InteractiveConnectionState,
+ reason: Int,
+ userId: String?
+ ) { ... }
+
+ override fun onError(code: String, message: String) { ... }
+
+ override fun onLocalVideoStats(stats: InteractiveStreamStats) { ... }
+
+ override fun onRemoteVideoStats(stats: InteractiveStreamStats) { ... }
+
+ override fun onMessageReceived(message: String, userId: String?) { ... }
+
+ override fun onTokenWillExpire(token: String?, expiresAt: Long) { ... }
+
+ override fun onTokenExpired(token: String?, expiresAt: Long) { ... }
+
+ override fun onDuration(durationSeconds: Long) { ... }
+
+ override fun onRemoteVideoEnabled(enabled: Boolean, userId: String?) { ... }
+
+ override fun onRemoteAudioEnabled(enabled: Boolean, userId: String?) { ... }
+
+ override fun onStreamStateChanged(
+ peerId: String,
+ state: RemoteState,
+ code: Int,
+ message: String?
+ ) { ... }
+}
+```
+
+**常见事件说明:**
+
+- `onConnectionStateChanged`:连接状态变化(Disconnected / Connecting / Connected / Reconnecting / Failed)
+- `onUserJoined` / `onUserLeave`:远端用户加入/离开频道
+- `onRemoteVideoEnabled` / `onRemoteAudioEnabled`:远端用户开关音视频
+- `onMessageReceived`:收到自定义消息
+- `onDuration`:通话时长更新(秒)
+- `onError`:错误回调(建议弹窗 + 打日志)
+
+---
+
+# 6. 通话统计信息
+
+## 6.1 单路流统计:InteractiveStreamStats
+
+在本地/远端视频统计回调中获取:
+
+```kotlin
+override fun onLocalVideoStats(stats: InteractiveStreamStats) {
+ // stats.width / height / fps / videoBitrateKbps / audioBitrateKbps / rttMs 等
+}
+
+override fun onRemoteVideoStats(stats: InteractiveStreamStats) {
+ // 针对某个 userId 的码率、分辨率、丢包、RTT 等
+}
+```
+
+你可以将这些信息显示在 UI 上,Demo 中的 `buildStatsLabel` 已经示范了如何构造:
+
+```kotlin
+private fun buildStatsLabel(header: String, stats: InteractiveStreamStats?): String {
+ // Res: WxH, FPS, Codec, Video/Audio Kbps, RTT 等
+}
+```
+
+## 6.2 通话结束时长:onLeaveChannel
+
+在 `onLeaveChannel` 中可以拿到本次通话时长(秒),无论是主动离开还是断网/失败结束,只要曾加入成功都会回调:
+
+```kotlin
+override fun onLeaveChannel(durationSeconds: Int) {
+ Log.d(TAG, "onLeaveChannel duration=${durationSeconds}s")
+}
+```
+
+---
+
+# 7. Token 过期机制
+
+SDK 在 Token 生命周期内会通过事件提醒你续期:
+
+## 7.1 Token 即将过期
+
+```kotlin
+override fun onTokenWillExpire(token: String?, expiresAt: Long) {
+ Toast.makeText(
+ this@InteractiveLiveActivity,
+ "Token 即将过期,请及时续期",
+ Toast.LENGTH_LONG
+ ).show()
+ // 1. 通知业务服务器刷新 Token
+ // 2. 拿到新 Token 后调用 rtcEngine?.renewToken(newToken)(具体接口以实际 SDK 为准)
+}
+```
+
+## 7.2 Token 已过期
+
+```kotlin
+override fun onTokenExpired(token: String?, expiresAt: Long) {
+ Toast.makeText(
+ this@InteractiveLiveActivity,
+ "Token 已过期,断线后将无法重连",
+ Toast.LENGTH_LONG
+ ).show()
+}
+```
+
+> 说明:
+> - Token 过期后,**当前通话不会立刻中断**,但网络异常时自动重连会失败。
+> - 请务必在 `onTokenWillExpire` 阶段就完成续期。
+
+---
+
+# 8. 常见问题 (FAQ)
+
+## Q1:多人远端画面如何渲染?
+
+为每一个远端用户(`userId`)分配一个 `SurfaceViewRenderer`,并调用:
+
+```kotlin
+val canvas = InteractiveVideoCanvas(renderer, userId)
+rtcEngine?.setupRemoteVideo(canvas)
+```
+
+在布局层面,你可以将多个 `renderer` 放到不同的容器中(网格布局 / 自定义九宫格等),参考 Demo 中的 `remoteSlots`。
+
+---
+
+## Q2:远端画面不显示怎么办?
+
+排查方向:
+
+1. 是否收到了 `onUserJoined` 回调?
+2. 有没有为该 `userId` 调用 `setupRemoteVideo` 并绑定到一个可见的 View?
+3. View 是否被其他控件覆盖?
+4. 远端用户是否已开启视频(可监听 `onRemoteVideoEnabled` 回调)?
+
+---
+
+## Q3:如何实现画中画 / 小窗布局?
+
+这是布局层面的工作,与 SDK 解耦:
+
+- 将远端大画面放在父容器(如 `FrameLayout`)中
+- 再将本地小窗 View 作为子 View 添加在右下角,并设置合适的 `layoutParams`
+- SDK 会把视频渲染到对应的 View 上,你只需要控制 View 的大小和位置即可
+
+---
+
+## Q4:如何在后台保持通话?
+
+Demo 中使用了一个前台 Service:
+
+```kotlin
+InteractiveForegroundService.start(this)
+// 离开频道后记得 stop
+InteractiveForegroundService.stop(this)
+```
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..c6c71ee
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,14 @@
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath('com.android.tools.build:gradle:8.11.1')
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.0")
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/example/build.gradle b/example/build.gradle
new file mode 100644
index 0000000..9cea37c
--- /dev/null
+++ b/example/build.gradle
@@ -0,0 +1,86 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+}
+
+def usePublishedSdk = (findProperty("usePublishedSdk")?.toString()?.toBoolean() ?: false)
+def sdkGroupId = rootProject.findProperty("sellySdkGroupId") ?: "com.sellycloud"
+def sdkArtifactId = rootProject.findProperty("sellySdkArtifactId") ?: "sellycloudsdk"
+def sdkVersion = rootProject.findProperty("sellySdkVersion") ?: "1.0.0"
+
+android {
+ namespace 'com.demo.SellyCloudSDK'
+ compileSdk 34
+
+ defaultConfig {
+ ndk {
+ abiFilters "armeabi-v7a", "arm64-v8a" // 同时打包 32/64 位
+ }
+ resConfigs "zh", "en" // 仅保留中文和英文资源
+ applicationId "com.demo.SellyCloudSDK"
+ minSdk 26
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ signingConfigs {
+ release {
+ def storePath = project.rootProject.file(findProperty("MY_STORE_FILE") ?: "")
+ if (storePath != null && storePath.exists()) {
+ storeFile storePath
+ } else {
+ storeFile project.rootProject.file(findProperty("MY_STORE_FILE") ?: "release.keystore")
+ }
+ storePassword findProperty("MY_STORE_PASSWORD") ?: ""
+ keyAlias findProperty("MY_KEY_ALIAS") ?: ""
+ keyPassword findProperty("MY_KEY_PASSWORD") ?: ""
+ v1SigningEnabled true
+ v2SigningEnabled true
+ }
+ }
+
+ buildTypes {
+ release {
+ shrinkResources false
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ signingConfig signingConfigs.release
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+ buildFeatures {
+ viewBinding true
+ }
+}
+
+dependencies {
+// SellyCloudSDK 需要的依赖(需要手动添加)
+ implementation 'com.google.code.gson:gson:2.10.1'
+ implementation 'com.github.pedroSG94.RootEncoder:library:2.6.6'
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
+ implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
+
+
+ implementation 'androidx.appcompat:appcompat:1.7.0-alpha03'
+ implementation 'androidx.constraintlayout:constraintlayout:2.2.0-alpha13'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
+ implementation 'androidx.core:core-ktx:1.13.1'
+
+ implementation 'androidx.activity:activity-ktx:1.9.2'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
+ implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.4'
+ implementation 'androidx.cardview:cardview:1.0.0'
+ implementation 'androidx.recyclerview:recyclerview:1.3.2'
+
+
+}
diff --git a/example/libs/Kiwi.aar b/example/libs/Kiwi.aar
new file mode 100644
index 0000000..63042fe
Binary files /dev/null and b/example/libs/Kiwi.aar differ
diff --git a/example/libs/fu_core_all_feature_release.aar b/example/libs/fu_core_all_feature_release.aar
new file mode 100644
index 0000000..0262d7e
Binary files /dev/null and b/example/libs/fu_core_all_feature_release.aar differ
diff --git a/example/libs/fu_model_all_feature_release.aar b/example/libs/fu_model_all_feature_release.aar
new file mode 100644
index 0000000..358da54
Binary files /dev/null and b/example/libs/fu_model_all_feature_release.aar differ
diff --git a/example/libs/libwebrtc.aar b/example/libs/libwebrtc.aar
new file mode 100644
index 0000000..3efccbc
Binary files /dev/null and b/example/libs/libwebrtc.aar differ
diff --git a/example/libs/sellycloudsdk-1.0.0.aar b/example/libs/sellycloudsdk-1.0.0.aar
new file mode 100644
index 0000000..9a76c7f
Binary files /dev/null and b/example/libs/sellycloudsdk-1.0.0.aar differ
diff --git a/example/proguard-rules.pro b/example/proguard-rules.pro
new file mode 100644
index 0000000..a19f305
--- /dev/null
+++ b/example/proguard-rules.pro
@@ -0,0 +1,7 @@
+# ProGuard/R8 rules (minimal placeholder). Adjust as needed when enabling minify.
+# Keep critical SDK classes if you later enable minify/shrinkResources.
+#-keep class org.webrtc.** { *; }
+#-dontwarn org.webrtc.**
+#-dontwarn com.google.android.exoplayer2.**
+#-dontwarn com.herohan.uvcapp.**
+
diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..231e78a
--- /dev/null
+++ b/example/src/main/AndroidManifest.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/FeatureHubActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/FeatureHubActivity.kt
new file mode 100644
index 0000000..2d13e38
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/FeatureHubActivity.kt
@@ -0,0 +1,32 @@
+package com.demo.SellyCloudSDK
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.demo.SellyCloudSDK.databinding.ActivityFeatureHubBinding
+import com.demo.SellyCloudSDK.interactive.InteractiveLiveActivity
+import com.demo.SellyCloudSDK.live.MainActivity
+
+/**
+ * Entry screen displaying available demo experiences.
+ */
+class FeatureHubActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityFeatureHubBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityFeatureHubBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ supportActionBar?.title = "SellyCloud SDK DEMO"
+
+ binding.cardLiveStreaming.setOnClickListener {
+ startActivity(Intent(this, MainActivity::class.java))
+ }
+
+ binding.cardInteractiveLive.setOnClickListener {
+ startActivity(Intent(this, InteractiveLiveActivity::class.java))
+ }
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/BeautyControlDialog.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/BeautyControlDialog.kt
new file mode 100644
index 0000000..8db7f4f
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/BeautyControlDialog.kt
@@ -0,0 +1,185 @@
+package com.demo.SellyCloudSDK.beauty
+//
+//import android.app.Dialog
+//import android.content.Context
+//import android.os.Bundle
+//import android.widget.SeekBar
+//import android.widget.TextView
+//import android.widget.Switch
+//import android.widget.Button
+//import android.view.Window
+//
+///**
+// * 美颜参数控制对话框
+// */
+//class BeautyControlDialog(
+// context: Context,
+//) : Dialog(context) {
+//
+// private lateinit var switchBeautyEnable: Switch
+// private lateinit var seekBarBeautyIntensity: SeekBar
+// private lateinit var seekBarFilterIntensity: SeekBar
+// private lateinit var seekBarColorIntensity: SeekBar
+// private lateinit var seekBarRedIntensity: SeekBar
+// private lateinit var seekBarEyeBrightIntensity: SeekBar
+// private lateinit var seekBarToothIntensity: SeekBar
+//
+// private lateinit var tvBeautyValue: TextView
+// private lateinit var tvFilterValue: TextView
+// private lateinit var tvColorValue: TextView
+// private lateinit var tvRedValue: TextView
+// private lateinit var tvEyeBrightValue: TextView
+// private lateinit var tvToothValue: TextView
+// private lateinit var btnClose: Button
+//
+// override fun onCreate(savedInstanceState: Bundle?) {
+// super.onCreate(savedInstanceState)
+// requestWindowFeature(Window.FEATURE_NO_TITLE)
+// setContentView(R.layout.dialog_beauty_control)
+//
+// initViews()
+// setupListeners()
+// updateUI()
+// }
+//
+// private fun initViews() {
+// switchBeautyEnable = findViewById(R.id.switchBeautyEnable)
+// seekBarBeautyIntensity = findViewById(R.id.seekBarBeautyIntensity)
+// seekBarFilterIntensity = findViewById(R.id.seekBarFilterIntensity)
+// seekBarColorIntensity = findViewById(R.id.seekBarColorIntensity)
+// seekBarRedIntensity = findViewById(R.id.seekBarRedIntensity)
+// seekBarEyeBrightIntensity = findViewById(R.id.seekBarEyeBrightIntensity)
+// seekBarToothIntensity = findViewById(R.id.seekBarToothIntensity)
+//
+// tvBeautyValue = findViewById(R.id.tvBeautyValue)
+// tvFilterValue = findViewById(R.id.tvFilterValue)
+// tvColorValue = findViewById(R.id.tvColorValue)
+// tvRedValue = findViewById(R.id.tvRedValue)
+// tvEyeBrightValue = findViewById(R.id.tvEyeBrightValue)
+// tvToothValue = findViewById(R.id.tvToothValue)
+// btnClose = findViewById(R.id.btnClose)
+// }
+//
+// private fun setupListeners() {
+// // 美颜开关
+// switchBeautyEnable.setOnCheckedChangeListener { _, isChecked ->
+// streamingService?.enableBeauty(isChecked)
+// // 根据开关状态启用/禁用参数调节
+// updateSeekBarsEnabled(isChecked)
+// }
+//
+// // 美颜强度调节 (0-100, 转换为0.0-10.0)
+// seekBarBeautyIntensity.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+// override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+// val intensity = progress / 10.0
+// tvBeautyValue.text = String.format("%.1f", intensity)
+// streamingService?.setBeautyIntensity(intensity)
+// }
+// override fun onStartTrackingTouch(seekBar: SeekBar?) {}
+// override fun onStopTrackingTouch(seekBar: SeekBar?) {}
+// })
+//
+// // 滤镜强度调节 (0-10, 转换为0.0-1.0)
+// seekBarFilterIntensity.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+// override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+// val intensity = progress / 10.0
+// tvFilterValue.text = String.format("%.1f", intensity)
+// streamingService?.setFilterIntensity(intensity)
+// }
+// override fun onStartTrackingTouch(seekBar: SeekBar?) {}
+// override fun onStopTrackingTouch(seekBar: SeekBar?) {}
+// })
+//
+// // 美白强度调节
+// seekBarColorIntensity.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+// override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+// val intensity = progress / 10.0
+// tvColorValue.text = String.format("%.1f", intensity)
+// streamingService?.setColorIntensity(intensity)
+// }
+// override fun onStartTrackingTouch(seekBar: SeekBar?) {}
+// override fun onStopTrackingTouch(seekBar: SeekBar?) {}
+// })
+//
+// // 红润强度调节
+// seekBarRedIntensity.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+// override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+// val intensity = progress / 10.0
+// tvRedValue.text = String.format("%.1f", intensity)
+// streamingService?.setRedIntensity(intensity)
+// }
+// override fun onStartTrackingTouch(seekBar: SeekBar?) {}
+// override fun onStopTrackingTouch(seekBar: SeekBar?) {}
+// })
+//
+// // 亮眼强度调节
+// seekBarEyeBrightIntensity.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+// override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+// val intensity = progress / 10.0
+// tvEyeBrightValue.text = String.format("%.1f", intensity)
+// streamingService?.setEyeBrightIntensity(intensity)
+// }
+// override fun onStartTrackingTouch(seekBar: SeekBar?) {}
+// override fun onStopTrackingTouch(seekBar: SeekBar?) {}
+// })
+//
+// // 美牙强度调节
+// seekBarToothIntensity.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+// override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+// val intensity = progress / 10.0
+// tvToothValue.text = String.format("%.1f", intensity)
+// streamingService?.setToothIntensity(intensity)
+// }
+// override fun onStartTrackingTouch(seekBar: SeekBar?) {}
+// override fun onStopTrackingTouch(seekBar: SeekBar?) {}
+// })
+//
+// // 关闭按钮
+// btnClose.setOnClickListener {
+// dismiss()
+// }
+// }
+//
+// private fun updateUI() {
+// // 获取当前美颜状态并更新UI
+// val isBeautyEnabled = streamingService?.isBeautyEnabled() ?: true
+// switchBeautyEnable.isChecked = isBeautyEnabled
+//
+// // 获取当前美颜参数
+// val params = streamingService?.getCurrentBeautyParams() ?: mapOf()
+//
+// // 设置各项参数的当前值
+// val blurIntensity = params["blurIntensity"] as? Double ?: 6.0
+// val filterIntensity = params["filterIntensity"] as? Double ?: 0.7
+// val colorIntensity = params["colorIntensity"] as? Double ?: 0.5
+// val redIntensity = params["redIntensity"] as? Double ?: 0.5
+// val eyeBrightIntensity = params["eyeBrightIntensity"] as? Double ?: 1.0
+// val toothIntensity = params["toothIntensity"] as? Double ?: 1.0
+//
+// seekBarBeautyIntensity.progress = (blurIntensity * 10).toInt()
+// seekBarFilterIntensity.progress = (filterIntensity * 10).toInt()
+// seekBarColorIntensity.progress = (colorIntensity * 10).toInt()
+// seekBarRedIntensity.progress = (redIntensity * 10).toInt()
+// seekBarEyeBrightIntensity.progress = (eyeBrightIntensity * 10).toInt()
+// seekBarToothIntensity.progress = (toothIntensity * 10).toInt()
+//
+// tvBeautyValue.text = String.format("%.1f", blurIntensity)
+// tvFilterValue.text = String.format("%.1f", filterIntensity)
+// tvColorValue.text = String.format("%.1f", colorIntensity)
+// tvRedValue.text = String.format("%.1f", redIntensity)
+// tvEyeBrightValue.text = String.format("%.1f", eyeBrightIntensity)
+// tvToothValue.text = String.format("%.1f", toothIntensity)
+//
+// // 根据开关状态启用/禁用参数调节
+// updateSeekBarsEnabled(isBeautyEnabled)
+// }
+//
+// private fun updateSeekBarsEnabled(enabled: Boolean) {
+// seekBarBeautyIntensity.isEnabled = enabled
+// seekBarFilterIntensity.isEnabled = enabled
+// seekBarColorIntensity.isEnabled = enabled
+// seekBarRedIntensity.isEnabled = enabled
+// seekBarEyeBrightIntensity.isEnabled = enabled
+// seekBarToothIntensity.isEnabled = enabled
+// }
+//}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FUBeautyFilterRender.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FUBeautyFilterRender.kt
new file mode 100644
index 0000000..19755fe
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FUBeautyFilterRender.kt
@@ -0,0 +1,257 @@
+package com.demo.SellyCloudSDK.beauty
+
+import android.content.Context
+import android.opengl.GLES20
+import android.opengl.Matrix
+import android.util.Log
+import com.demo.SellyCloudSDK.R
+import com.pedro.encoder.input.gl.render.filters.BaseFilterRender
+import com.pedro.encoder.utils.gl.GlUtil
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * FaceUnity beauty filter that plugs into RootEncoder's GL filter chain.
+ * 优化后台兼容性,避免依赖Activity上下文
+ */
+class FUBeautyFilterRender(
+ private val fuRenderer: FURenderer
+) : BaseFilterRender() {
+
+ private val TAG = "FUBeautyFilterRender"
+
+ // 美颜开关状态
+ private var isBeautyEnabled = true
+
+ // 添加摄像头朝向跟踪
+ private var currentCameraFacing: com.pedro.encoder.input.video.CameraHelper.Facing =
+ com.pedro.encoder.input.video.CameraHelper.Facing.BACK
+
+ // Standard vertex data following pedro's pattern (X, Y, Z, U, V)
+ private val squareVertexDataFilter = floatArrayOf(
+ // X, Y, Z, U, V
+ -1f, -1f, 0f, 0f, 0f, // bottom left
+ 1f, -1f, 0f, 1f, 0f, // bottom right
+ -1f, 1f, 0f, 0f, 1f, // top left
+ 1f, 1f, 0f, 1f, 1f // top right
+ )
+
+ private var frameW = 0
+ private var frameH = 0
+ private lateinit var appContext: Context
+
+ // GLSL program and handles
+ private var program = -1
+ private var aPositionHandle = -1
+ private var aTextureHandle = -1
+ private var uMVPMatrixHandle = -1
+ private var uSTMatrixHandle = -1
+ private var uSamplerHandle = -1
+
+ // 添加初始化状态检查
+ private var isInitialized = false
+
+ init {
+ squareVertex = ByteBuffer.allocateDirect(squareVertexDataFilter.size * FLOAT_SIZE_BYTES)
+ .order(ByteOrder.nativeOrder())
+ .asFloatBuffer()
+ squareVertex.put(squareVertexDataFilter).position(0)
+ Matrix.setIdentityM(MVPMatrix, 0)
+ Matrix.setIdentityM(STMatrix, 0)
+ }
+
+ override fun initGl(
+ width: Int,
+ height: Int,
+ context: Context,
+ previewWidth: Int,
+ previewHeight: Int
+ ) {
+ super.initGl(width, height, context, previewWidth, previewHeight)
+ // 确保使用 ApplicationContext,避免Activity依赖
+ this.appContext = context.applicationContext
+ frameW = width
+ frameH = height
+ Log.d(TAG, "initGl: width=$width, height=$height, context=${context.javaClass.simpleName}")
+ }
+
+ override fun initGlFilter(context: Context?) {
+ if (isInitialized) {
+ Log.d(TAG, "Filter already initialized. Skipping initGlFilter.")
+ return
+ }
+ try {
+ // 使用 ApplicationContext 避免Activity依赖
+ val safeContext = context?.applicationContext ?: appContext
+
+ val vertexShader = GlUtil.getStringFromRaw(safeContext, R.raw.simple_vertex)
+ val fragmentShader = GlUtil.getStringFromRaw(safeContext, R.raw.fu_base_fragment)
+
+ program = GlUtil.createProgram(vertexShader, fragmentShader)
+ aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
+ aTextureHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
+ uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
+ uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
+ uSamplerHandle = GLES20.glGetUniformLocation(program, "uSampler")
+
+ isInitialized = true
+ Log.d(TAG, "initGlFilter completed - program: $program")
+ } catch (e: Exception) {
+ Log.e(TAG, "initGlFilter failed", e)
+ isInitialized = false
+ }
+ }
+
+ /**
+ * 设置摄像头朝向(供外部调用)
+ */
+ fun setCameraFacing(facing: com.pedro.encoder.input.video.CameraHelper.Facing) {
+ currentCameraFacing = facing
+ fuRenderer.setCameraFacing(facing)
+ Log.d(TAG, "Camera facing updated: $facing")
+ }
+
+ /**
+ * Core render step called by BaseFilterRender every frame.
+ */
+ override fun drawFilter() {
+ // 增加初始化检查
+ if (!isInitialized) {
+ Log.w(TAG, "Filter not initialized, skipping draw")
+ return
+ }
+
+ // 如果美颜被禁用,使用简单的纹理透传渲染
+ if (!isBeautyEnabled) {
+ drawPassThrough()
+ return
+ }
+
+ if (!fuRenderer.isAuthSuccess || fuRenderer.fuRenderKit == null) {
+ // Fallback: 使用透传渲染而不是直接return
+ drawPassThrough()
+ return
+ }
+
+ if (previousTexId <= 0 || frameW <= 0 || frameH <= 0) {
+ return
+ }
+
+ try {
+ // 保存当前 FBO 与 viewport,避免外部库改写
+ val prevFbo = IntArray(1)
+ val prevViewport = IntArray(4)
+ GLES20.glGetIntegerv(GLES20.GL_FRAMEBUFFER_BINDING, prevFbo, 0)
+ GLES20.glGetIntegerv(GLES20.GL_VIEWPORT, prevViewport, 0)
+
+ // 使用带朝向的渲染方法
+ val processedTexId = fuRenderer.onDrawFrame(previousTexId, frameW, frameH, currentCameraFacing)
+
+ // 还原 FBO 与 viewport,避免黑屏
+ GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, prevFbo[0])
+ GLES20.glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3])
+
+ // Use processed texture if available, otherwise fallback to original
+ val textureIdToDraw = if (processedTexId > 0) processedTexId else previousTexId
+
+ // Now draw using our own shader program
+ GLES20.glUseProgram(program)
+
+ // Set vertex position
+ squareVertex.position(SQUARE_VERTEX_DATA_POS_OFFSET)
+ GLES20.glVertexAttribPointer(aPositionHandle, 3, GLES20.GL_FLOAT, false,
+ SQUARE_VERTEX_DATA_STRIDE_BYTES, squareVertex)
+ GLES20.glEnableVertexAttribArray(aPositionHandle)
+
+ // Set texture coordinates
+ squareVertex.position(SQUARE_VERTEX_DATA_UV_OFFSET)
+ GLES20.glVertexAttribPointer(aTextureHandle, 2, GLES20.GL_FLOAT, false,
+ SQUARE_VERTEX_DATA_STRIDE_BYTES, squareVertex)
+ GLES20.glEnableVertexAttribArray(aTextureHandle)
+
+ // Set transformation matrices
+ GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, MVPMatrix, 0)
+ GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, STMatrix, 0)
+
+ // Bind texture and draw
+ GLES20.glUniform1i(uSamplerHandle, 0)
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureIdToDraw)
+
+ // Draw the rectangle
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error in beauty processing", e)
+ // Fallback: 使用透传渲染
+ drawPassThrough()
+ }
+ }
+
+ /**
+ * 透传渲染:直接渲染原始纹理,不进行美颜处理
+ */
+ private fun drawPassThrough() {
+ if (previousTexId <= 0 || !isInitialized) {
+ return
+ }
+
+ try {
+ // 使用原始纹理进行渲染
+ GLES20.glUseProgram(program)
+
+ // Set vertex position
+ squareVertex.position(SQUARE_VERTEX_DATA_POS_OFFSET)
+ GLES20.glVertexAttribPointer(aPositionHandle, 3, GLES20.GL_FLOAT, false,
+ SQUARE_VERTEX_DATA_STRIDE_BYTES, squareVertex)
+ GLES20.glEnableVertexAttribArray(aPositionHandle)
+
+ // Set texture coordinates
+ squareVertex.position(SQUARE_VERTEX_DATA_UV_OFFSET)
+ GLES20.glVertexAttribPointer(aTextureHandle, 2, GLES20.GL_FLOAT, false,
+ SQUARE_VERTEX_DATA_STRIDE_BYTES, squareVertex)
+ GLES20.glEnableVertexAttribArray(aTextureHandle)
+
+ // Set transformation matrices
+ GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, MVPMatrix, 0)
+ GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, STMatrix, 0)
+
+ // Bind original texture and draw
+ GLES20.glUniform1i(uSamplerHandle, 0)
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, previousTexId)
+
+ // Draw the rectangle
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error in pass-through rendering", e)
+ }
+ }
+
+ override fun disableResources() {
+ GlUtil.disableResources(aTextureHandle, aPositionHandle)
+ }
+
+ override fun release() {
+ if (program != -1) {
+ GLES20.glDeleteProgram(program)
+ program = -1
+ }
+ isInitialized = false
+ Log.d(TAG, "FUBeautyFilterRender released")
+ }
+
+ /**
+ * 设置美颜开关状态
+ */
+ fun setBeautyEnabled(enabled: Boolean) {
+ isBeautyEnabled = enabled
+ Log.d(TAG, "Beauty enabled: $enabled")
+ }
+
+ /**
+ * 获取美颜开关状态
+ */
+ fun isBeautyEnabled(): Boolean = isBeautyEnabled
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FURenderer.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FURenderer.kt
new file mode 100644
index 0000000..669fa99
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FURenderer.kt
@@ -0,0 +1,301 @@
+package com.demo.SellyCloudSDK.beauty
+
+import android.content.Context
+import android.util.Log
+import com.faceunity.core.callback.OperateCallback
+import com.faceunity.core.entity.FUBundleData
+import com.faceunity.core.entity.FURenderInputData
+import com.faceunity.core.enumeration.CameraFacingEnum
+import com.faceunity.core.enumeration.FUAITypeEnum
+import com.faceunity.core.enumeration.FUExternalInputEnum
+import com.faceunity.core.enumeration.FUInputTextureEnum
+import com.faceunity.core.enumeration.FUTransformMatrixEnum
+import com.faceunity.core.faceunity.FUAIKit
+import com.faceunity.core.faceunity.FURenderKit
+import com.faceunity.core.faceunity.FURenderManager
+import com.faceunity.core.model.facebeauty.FaceBeauty
+import com.faceunity.core.utils.FULogger
+import com.faceunity.wrapper.faceunity
+import com.pedro.encoder.input.video.CameraHelper
+import java.io.File
+import java.io.IOException
+import java.util.concurrent.Executors
+
+
+/**
+ * 相芯美颜 SDK 工具类
+ */
+class FURenderer(private val context: Context) {
+ private val TAG = "FURenderer"
+
+ /* 特效FURenderKit*/
+ var fuRenderKit: FURenderKit? = null
+ private set
+ private val fuAIKit: FUAIKit = FUAIKit.getInstance()
+
+ /* 当前生效美颜数据模型 */
+ var faceBeauty: FaceBeauty? = null
+ private set
+
+ // SDK 是否验证成功
+ @Volatile
+ var isAuthSuccess = false
+ private set
+
+ // 添加 GL 初始化状态标记
+ @Volatile
+ private var isGlInitialized = false
+
+ private val BUNDLE_AI_FACE = "model" + File.separator + "ai_face_processor.bundle"
+ private val BUNDLE_AI_HUMAN = "model" + File.separator + "ai_human_processor.bundle"
+ private val BUNDLE_FACE_BEAUTY = "graphics" + File.separator + "face_beautification.bundle"
+
+ private val workerThread = Executors.newSingleThreadExecutor()
+
+ // 添加摄像头朝向管理
+ private var currentCameraFacing: CameraHelper.Facing = CameraHelper.Facing.BACK
+
+ /**
+ * 初始化美颜SDK
+ */
+ fun setup() {
+ workerThread.execute {
+ Log.d(TAG, "FURenderer setup start")
+
+ FURenderManager.setKitDebug(FULogger.LogLevel.ERROR)
+ FURenderManager.setCoreDebug(FULogger.LogLevel.ERROR)
+
+ // 使用正确的证书变量
+ FURenderManager.registerFURender(context, authpack.A, object : OperateCallback {
+ override fun onSuccess(code: Int, msg: String) {
+ Log.d(TAG, "美颜SDK验证成功: code=$code, msg=$msg")
+ isAuthSuccess = true
+
+ // 初始化成功后,在后台线程加载所需资源
+ workerThread.submit {
+ try {
+ faceunity.fuSetUseTexAsync(1)
+ // 获取 FURenderKit 实例
+ fuRenderKit = FURenderKit.getInstance()
+
+ // 加载 AI 模型
+ fuAIKit.loadAIProcessor(
+ BUNDLE_AI_FACE,
+ FUAITypeEnum.FUAITYPE_FACEPROCESSOR
+ )
+ fuAIKit.loadAIProcessor(
+ BUNDLE_AI_HUMAN,
+ FUAITypeEnum.FUAITYPE_HUMAN_PROCESSOR
+ )
+
+ fuAIKit.setFaceDelayLeaveEnable(false)
+ // 根据相芯版本,此方法可能不存在或有变动
+ fuAIKit.faceProcessorSetFaceLandmarkQuality(1)
+
+ // 加载美颜道具
+ loadBeautyBundle()
+
+ // 将美颜效果应用到 fuRenderKit
+ fuRenderKit?.faceBeauty = faceBeauty
+ Log.d(TAG, "FaceUnity 资源加载完成。")
+ } catch (e: Exception) {
+ Log.e(TAG, "FaceUnity 资源加载失败", e)
+ isAuthSuccess = false
+ }
+ }
+ }
+
+ override fun onFail(errCode: Int, errMsg: String) {
+ Log.e(TAG, "美颜SDK验证失败: code=$errCode, msg=$errMsg")
+ isAuthSuccess = false
+ }
+ })
+ }
+ }
+
+ /** 设置摄像头朝向(供外部调用) */
+ fun setCameraFacing(facing: CameraHelper.Facing) {
+ currentCameraFacing = facing
+ Log.d(TAG, "camera facing -> $facing")
+ }
+
+ /**
+ * 当 OpenGL 上下文被销毁/重建(例如切换到 WHIP 再返回)时调用,
+ * 释放并重建与 GL 相关的资源,避免 FBO/Program 失效导致黑屏或 GL 错误。
+ */
+ fun onGlContextRecreated() {
+ if (!isAuthSuccess) {
+ Log.w(TAG, "onGlContextRecreated skipped: auth not ready")
+ return
+ }
+ try {
+ Log.d(TAG, "onGlContextRecreated: begin")
+ // 释放并重新获取渲染实例(绑定到当前 GL 上下文)
+ try { fuRenderKit?.release() } catch (_: Throwable) {}
+ fuRenderKit = FURenderKit.getInstance()
+ // 重新应用美颜参数与道具
+ if (faceBeauty == null) loadBeautyBundle()
+ fuRenderKit?.faceBeauty = faceBeauty
+ // 再次开启异步纹理模式(稳妥起见)
+ try { faceunity.fuSetUseTexAsync(1) } catch (_: Throwable) {}
+ Log.d(TAG, "onGlContextRecreated: done")
+ } catch (e: Exception) {
+ Log.e(TAG, "onGlContextRecreated error", e)
+ }
+ }
+
+ /** 兼容原方法:沿用当前已记录的朝向 */
+ fun onDrawFrame(inputTex: Int, width: Int, height: Int): Int =
+ onDrawFrame(inputTex, width, height, currentCameraFacing)
+
+ /** 带朝向的渲染入口(推荐) */
+ fun onDrawFrame(inputTex: Int, width: Int, height: Int, facing: CameraHelper.Facing): Int {
+ // 更新记录的朝向
+ currentCameraFacing = facing
+
+ // 检查 SDK 和 GL 是否就绪
+ if (!isAuthSuccess || !isGlInitialized || fuRenderKit == null) {
+ // 如果认证成功但 GL 未初始化,尝试初始化
+ if (isAuthSuccess && !isGlInitialized) {
+ Log.w(TAG, "GL not initialized, attempting to initialize")
+ reinitializeGlContext()
+ }
+ return inputTex
+ }
+
+ // SDK 未就绪则透传
+ if (inputTex <= 0 || width <= 0 || height <= 0) return inputTex
+
+ return try {
+ val renderInput = FURenderInputData(width, height).apply {
+ texture = FURenderInputData.FUTexture(
+ inputTextureType = FUInputTextureEnum.FU_ADM_FLAG_COMMON_TEXTURE,
+ texId = inputTex
+ )
+ renderConfig.apply {
+ // 根据前后摄设置矩阵,修复镜像/旋转
+ when (currentCameraFacing) {
+ CameraHelper.Facing.FRONT -> {
+ // 前置:水平镜像修正(FLIPVERTICAL/FLIPHORIZONTAL 依设备坐标系可能不同)
+ inputTextureMatrix = FUTransformMatrixEnum.CCROT0_FLIPVERTICAL
+ inputBufferMatrix = FUTransformMatrixEnum.CCROT0_FLIPVERTICAL
+ outputMatrix = FUTransformMatrixEnum.CCROT0
+ cameraFacing = CameraFacingEnum.CAMERA_FRONT
+ }
+
+ CameraHelper.Facing.BACK -> {
+ inputTextureMatrix = FUTransformMatrixEnum.CCROT0_FLIPVERTICAL
+ inputBufferMatrix = FUTransformMatrixEnum.CCROT0_FLIPVERTICAL
+ outputMatrix = FUTransformMatrixEnum.CCROT0
+ cameraFacing = CameraFacingEnum.CAMERA_BACK
+ }
+ }
+ // 设备/输入朝向:交给外层统一置0,避免 180° 误旋
+ deviceOrientation = 0
+
+ // 外部输入:相机
+ externalInputType = FUExternalInputEnum.EXTERNAL_INPUT_TYPE_CAMERA
+ }
+ }
+ val output = fuRenderKit!!.renderWithInput(renderInput)
+ output.texture?.texId?.takeIf { it > 0 } ?: inputTex
+ } catch (e: Exception) {
+ Log.e(TAG, "render error", e)
+ inputTex
+ }
+ }
+
+ /**
+ * 加载美颜道具并设置默认参数
+ */
+ private fun loadBeautyBundle() {
+ try {
+ faceBeauty = FaceBeauty(FUBundleData(BUNDLE_FACE_BEAUTY))
+ // 设置默认美颜效果
+ faceBeauty?.let {
+ it.filterName = "origin"
+ it.filterIntensity = 0.7
+ it.blurIntensity = 6.0
+ it.colorIntensity = 0.5
+ it.redIntensity = 0.5
+ it.eyeBrightIntensity = 1.0
+ it.toothIntensity = 1.0
+ }
+ Log.d(TAG, "Beauty bundle loaded successfully")
+ } catch (e: IOException) {
+ Log.e(TAG, "加载美颜道具失败", e)
+ }
+ }
+
+ /**
+ * 释放 GL 相关资源(协议切换时调用)
+ */
+ fun releaseGlContext() {
+ if (!isAuthSuccess) return
+
+ workerThread.execute {
+ try {
+ Log.d(TAG, "Releasing GL context resources for protocol switch")
+ isGlInitialized = false
+
+ // 释放渲染器的 GL 资源
+ fuRenderKit?.release()
+ fuRenderKit = null
+
+ // 注意:不清空 faceBeauty,保留美颜参数配置
+ Log.d(TAG, "GL context resources released successfully")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error releasing GL context", e)
+ }
+ }
+ }
+
+ /**
+ * 重新初始化 GL 上下文(协议切换后调用)
+ */
+ fun reinitializeGlContext() {
+ if (!isAuthSuccess) return
+
+ workerThread.execute {
+ try {
+ Log.d(TAG, "Reinitializing GL context after protocol switch")
+
+ // 重新获取 FURenderKit 实例(绑定到新的 GL 上下文)
+ fuRenderKit = FURenderKit.getInstance()
+
+ // 重新设置异步纹理模式
+ faceunity.fuSetUseTexAsync(1)
+
+ // 如果之前有美颜配置,重新应用
+ if (faceBeauty != null) {
+ fuRenderKit?.faceBeauty = faceBeauty
+ Log.d(TAG, "Beauty configuration reapplied")
+ }
+
+ isGlInitialized = true
+ Log.d(TAG, "GL context reinitialized successfully")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reinitializing GL context", e)
+ isGlInitialized = false
+ }
+ }
+ }
+
+ /**
+ * 释放资源
+ */
+ fun release() {
+ Log.d(TAG, "Releasing FURenderer resources")
+ isGlInitialized = false
+ try {
+ fuRenderKit?.release()
+ } catch (_: Exception) {}
+ fuRenderKit = null
+ fuAIKit.releaseAllAIProcessor()
+ faceBeauty = null
+ isAuthSuccess = false
+ try {
+ workerThread.shutdown()
+ } catch (_: Exception) {}
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FaceUnityBeautyEngine.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FaceUnityBeautyEngine.kt
new file mode 100644
index 0000000..d0d4841
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FaceUnityBeautyEngine.kt
@@ -0,0 +1,114 @@
+package com.demo.SellyCloudSDK.beauty
+
+import android.content.Context
+import android.util.Log
+import com.pedro.encoder.input.gl.render.filters.BaseFilterRender
+import com.pedro.encoder.input.video.CameraHelper
+import com.sellycloud.sellycloudsdk.VideoFrameInterceptor
+import com.sellycloud.sellycloudsdk.beauty.BeautyEngine
+
+/**
+ * FaceUnity based beauty engine implementation that adapts the SDK's beauty hooks
+ * to the host application's FaceUnity integration.
+ */
+class FaceUnityBeautyEngine : BeautyEngine {
+
+ private val tag = "FaceUnityBeautyEng"
+
+ private var renderer: FURenderer? = null
+ private var filter: FUBeautyFilterRender? = null
+ private var whipInterceptor: FuVideoFrameInterceptor? = null
+
+ private var initialized = false
+ private var enabled = true
+ private var intensity = DEFAULT_INTENSITY
+ private var currentFacing: CameraHelper.Facing = CameraHelper.Facing.FRONT
+
+ override fun initialize(context: Context) {
+ if (initialized) return
+ kotlin.runCatching {
+ val appCtx = context.applicationContext
+ val fuRenderer = FURenderer(appCtx).also { it.setup() }
+ renderer = fuRenderer
+
+ filter = FUBeautyFilterRender(fuRenderer).apply {
+ setBeautyEnabled(enabled)
+ setCameraFacing(currentFacing)
+ }
+
+ whipInterceptor = FuVideoFrameInterceptor(fuRenderer).apply {
+ setFrontCamera(currentFacing == CameraHelper.Facing.FRONT)
+ }
+
+ applyIntensity()
+ initialized = true
+ Log.d(tag, "FaceUnity beauty engine initialized")
+ }.onFailure {
+ Log.e(tag, "Failed to initialize FaceUnity beauty engine", it)
+ release()
+ }
+ }
+
+ override fun obtainFilter(): BaseFilterRender? {
+ applyIntensity()
+ return filter
+ }
+
+ override fun obtainWhipInterceptor(): VideoFrameInterceptor? {
+ applyIntensity()
+ return whipInterceptor
+ }
+
+ override fun setEnabled(enabled: Boolean) {
+ this.enabled = enabled
+ filter?.setBeautyEnabled(enabled)
+ }
+
+ override fun setIntensity(intensity: Double) {
+ this.intensity = intensity
+ applyIntensity()
+ }
+
+ override fun onCameraFacingChanged(facing: CameraHelper.Facing) {
+ currentFacing = facing
+ filter?.setCameraFacing(facing)
+ whipInterceptor?.setFrontCamera(facing == CameraHelper.Facing.FRONT)
+ }
+
+ override fun onBeforeGlContextRelease() {
+ kotlin.runCatching { renderer?.releaseGlContext() }
+ }
+
+ override fun onAfterGlContextRecreated() {
+ kotlin.runCatching { renderer?.reinitializeGlContext() }
+ applyIntensity()
+ }
+
+ override fun onGlContextRecreated() {
+ kotlin.runCatching { renderer?.onGlContextRecreated() }
+ applyIntensity()
+ }
+
+ override fun release() {
+ kotlin.runCatching { filter?.release() }
+ kotlin.runCatching { renderer?.release() }
+ filter = null
+ renderer = null
+ whipInterceptor = null
+ initialized = false
+ }
+
+ private fun applyIntensity() {
+ val faceBeauty = renderer?.faceBeauty
+ if (faceBeauty != null) {
+ faceBeauty.blurIntensity = intensity
+ renderer?.fuRenderKit?.faceBeauty = faceBeauty
+ } else {
+ Log.d(tag, "faceBeauty not ready yet, defer intensity apply")
+ }
+ }
+
+ companion object {
+ private const val DEFAULT_INTENSITY = 3.0
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt
new file mode 100644
index 0000000..a6b8f7b
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt
@@ -0,0 +1,162 @@
+package com.demo.SellyCloudSDK.beauty
+
+import android.util.Log
+import com.sellycloud.sellycloudsdk.VideoFrameInterceptor
+import com.faceunity.core.entity.FURenderInputData
+import com.faceunity.core.enumeration.CameraFacingEnum
+import com.faceunity.core.enumeration.FUExternalInputEnum
+import com.faceunity.core.enumeration.FUInputBufferEnum
+import com.faceunity.core.enumeration.FUTransformMatrixEnum
+import org.webrtc.JavaI420Buffer
+import org.webrtc.VideoFrame
+
+/**
+ * 将 WebRTC 采集的 I420 帧交给 FaceUnity 进行美颜,返回处理后的 NV21 帧。
+ * 最小化侵入:当 SDK 未就绪或出错时,返回 null 让上游透传原始帧。
+ *
+ * 重要:此拦截器不管理传入帧的生命周期,只负责创建新的处理后帧。
+ */
+class FuVideoFrameInterceptor(
+ private val fuRenderer: FURenderer
+) : VideoFrameInterceptor {
+
+ private val tag = "FuVideoFrameInt"
+
+ @Volatile private var isFrontCamera: Boolean = true
+ @Volatile private var enabled: Boolean = true
+ fun setFrontCamera(front: Boolean) { isFrontCamera = front }
+ fun setEnabled(enable: Boolean) { enabled = enable }
+
+ override fun process(frame: VideoFrame): VideoFrame? {
+ if (!enabled) return null
+ val kit = fuRenderer.fuRenderKit
+ if (!fuRenderer.isAuthSuccess || kit == null) return null
+
+ val src = frame.buffer
+ // 兼容部分 webrtc 版本中 toI420 可能标注为可空的情况
+ val i420Maybe = try { src.toI420() } catch (_: Throwable) { null }
+ val i420 = i420Maybe ?: return null
+
+ return try {
+ val width = i420.width
+ val height = i420.height
+ if (width == 0 || height == 0) return null
+
+ val i420Bytes = toI420Bytes(i420)
+
+ val inputData = FURenderInputData(width, height).apply {
+ imageBuffer = FURenderInputData.FUImageBuffer(
+ FUInputBufferEnum.FU_FORMAT_I420_BUFFER,
+ i420Bytes
+ )
+ renderConfig.apply {
+ externalInputType = FUExternalInputEnum.EXTERNAL_INPUT_TYPE_IMAGE
+ if (isFrontCamera) {
+ cameraFacing = CameraFacingEnum.CAMERA_FRONT
+ inputTextureMatrix = FUTransformMatrixEnum.CCROT0_FLIPVERTICAL
+ inputBufferMatrix = FUTransformMatrixEnum.CCROT0_FLIPVERTICAL
+ outputMatrix = FUTransformMatrixEnum.CCROT0
+ } else {
+ cameraFacing = CameraFacingEnum.CAMERA_BACK
+ inputTextureMatrix = FUTransformMatrixEnum.CCROT0
+ inputBufferMatrix = FUTransformMatrixEnum.CCROT0
+ outputMatrix = FUTransformMatrixEnum.CCROT0_FLIPVERTICAL
+ }
+ isNeedBufferReturn = true
+ }
+ }
+
+ val output = kit.renderWithInput(inputData)
+ val outImage = output.image ?: return null
+ val outI420 = outImage.buffer ?: return null
+ if (outI420.isEmpty()) return null
+
+ // 安全:将 I420 字节填充到 JavaI420Buffer,避免手写 NV21 转换越界
+ val jbuf = fromI420BytesToJavaI420(outI420, width, height)
+ VideoFrame(jbuf, frame.rotation, frame.timestampNs)
+ } catch (t: Throwable) {
+ Log.w(tag, "beauty failed: ${t.message}")
+ null
+ } finally {
+ // 只释放我们创建的 I420Buffer,不释放原始 frame
+ try { i420.release() } catch (_: Throwable) {}
+ }
+ }
+
+ private fun toI420Bytes(i420: VideoFrame.I420Buffer): ByteArray {
+ val w = i420.width
+ val h = i420.height
+ val ySize = w * h
+ val uvW = (w + 1) / 2
+ val uvH = (h + 1) / 2
+ val uSize = uvW * uvH
+ val vSize = uSize
+ val out = ByteArray(ySize + uSize + vSize)
+ val yBuf = i420.dataY
+ val uBuf = i420.dataU
+ val vBuf = i420.dataV
+ val yStride = i420.strideY
+ val uStride = i420.strideU
+ val vStride = i420.strideV
+ // copy Y
+ var dst = 0
+ for (j in 0 until h) {
+ val srcPos = j * yStride
+ yBuf.position(srcPos)
+ yBuf.get(out, dst, w)
+ dst += w
+ }
+ // copy U
+ for (j in 0 until uvH) {
+ val srcPos = j * uStride
+ uBuf.position(srcPos)
+ uBuf.get(out, ySize + j * uvW, uvW)
+ }
+ // copy V
+ for (j in 0 until uvH) {
+ val srcPos = j * vStride
+ vBuf.position(srcPos)
+ vBuf.get(out, ySize + uSize + j * uvW, uvW)
+ }
+ return out
+ }
+
+ // 将连续 I420 字节拷贝到 JavaI420Buffer
+ private fun fromI420BytesToJavaI420(i420: ByteArray, width: Int, height: Int): JavaI420Buffer {
+ val ySize = width * height
+ val uvW = (width + 1) / 2
+ val uvH = (height + 1) / 2
+ val uSize = uvW * uvH
+ val vSize = uSize
+ require(i420.size >= ySize + uSize + vSize) { "I420 buffer too small: ${i420.size}" }
+ val buf = JavaI420Buffer.allocate(width, height)
+ val y = buf.dataY
+ val u = buf.dataU
+ val v = buf.dataV
+ val yStride = buf.strideY
+ val uStride = buf.strideU
+ val vStride = buf.strideV
+ // 拷贝 Y
+ var src = 0
+ for (j in 0 until height) {
+ y.position(j * yStride)
+ y.put(i420, src, width)
+ src += width
+ }
+ // 拷贝 U
+ var uSrc = ySize
+ for (j in 0 until uvH) {
+ u.position(j * uStride)
+ u.put(i420, uSrc, uvW)
+ uSrc += uvW
+ }
+ // 拷贝 V
+ var vSrc = ySize + uSize
+ for (j in 0 until uvH) {
+ v.position(j * vStride)
+ v.put(i420, vSrc, uvW)
+ vSrc += uvW
+ }
+ return buf
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/authpack.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/authpack.kt
new file mode 100644
index 0000000..e4119cc
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/authpack.kt
@@ -0,0 +1,8 @@
+package com.demo.SellyCloudSDK.beauty
+
+/**
+ * 请将相芯提供的证书密钥填充到此处的 AUTH_ARRAYS 字节数组中
+ */
+object authpack {
+ val A: ByteArray = byteArrayOf(79, 120, -34, -73, -66, -116, 90, 16, -26, -94, -75, 3, 119, 83, 41, -46, -50, 46, 81, 72, 78, 80, -7, 110, -61, -31, 55, -90, -123, 96, -57, -79, 38, -45, -63, -8, 51, 103, 112, -83, -44, -16, -36, 101, 49, -82, 43, -33, -21, 4, -28, 10, -91, -23, -67, -27, -19, 75, 66, -44, -128, -41, -66, 65, 81, -118, -85, -69, 56, -80, 6, 8, 87, -114, -27, 96, 28, -39, -68, -10, -92, -67, 118, -119, 18, -87, 116, -94, -9, -42, -19, -30, -12, 86, 56, -90, -21, 69, 67, 61, 26, 101, -45, 77, 51, 111, 17, -10, 33, 57, -28, 80, 56, 31, -97, -106, -6, 71, 95, -119, -90, -15, 43, 10, 104, 45, 98, -91, -13, -14, -24, 3, 43, -18, -45, -5, -74, 47, -119, -6, -39, 60, 87, 0, -126, -99, -92, 122, 43, 94, 107, -34, 8, 29, -83, 91, -76, 31, 14, -113, -69, -125, 110, -69, -87, -60, 87, 112, 125, 99, -120, -18, 107, 101, 93, 99, -30, -51, -8, -72, -42, 106, 99, -30, -9, -94, -120, -41, 0, -17, -66, 93, -103, 79, -77, -62, 97, 95, -86, -113, 4, -11, 10, -67, 91, 1, -84, 76, -56, 127, 41, 7, -78, 79, 71, 28, 100, 123, -37, -47, -107, -92, -126, -39, 69, -83, -38, 104, -58, -36, 63, -101, 2, 82, -63, 63, -50, 104, -48, 46, -84, 124, 63, -73, 21, -2, 101, 73, -116, -90, -63, -104, 75, -75, -76, -85, -24, -69, 31, -107, -51, -42, 9, -112, -12, 74, 2, -17, -62, -58, 107, 96, 21, 70, -103, 69, -101, 99, -97, 59, 53, 111, -64, -25, 112, -69, 122, 84, -89, -63, -116, 35, 111, 2, 121, -67, 5, 106, 34, -50, 79, 116, 17, 83, 79, 22, -126, 5, -26, 84, -68, -35, 53, 0, -87, -6, 75, -57, -29, 92, 98, 13, 32, -21, 20, 93, -107, 109, -21, -110, -78, -121, -26, -65, -88, 76, 100, -82, -32, -96, 95, 77, -88, -59, -47, 78, -47, -124, -24, 127, 39, -115, 78, -101, -109, 97, 120, -113, 95, -59, -91, 88, -67, 80, -63, -67, 35, -79, -77, 58, 118, -27, -13, 67, -125, 107, 9, -54, 107, -18, 5, 93, -7, 67, 117, -79, 80, 82, -61, -96, 39, -128, -64, -86, -22, -34, -19, 103, -8, -32, 56, 110, -83, 113, -26, 69, -122, -22, 55, -74, 124, 25, -83, -28, -11, 66, -17, 3, 94, 105, -83, 57, 23, 37, 109, 13, -39, 26, -127, -85, 9, -43, 17, -35, -36, 113, 72, -126, -108, 49, 2, 123, 9, 51, -42, 89, 84, -1, -46, -83, -59, 111, 21, 125, 11, 94, 54, -120, -20, -52, 90, -16, 55, -107, -77, -59, 76, 82, -45, 117, 113, -43, 71, 58, 11, 96, 94, 123, 11, 65, -67, -8, -114, -95, 104, -13, 8, 46, 15, -84, -118, -63, -14, 58, 75, 64, 2, 90, -35, 31, -110, 0, -78, 93, -83, 73, 55, 24, -46, 105, 70, 98, 110, 22, -17, -52, 120, 7, -103, -83, -78, -51, -60, -114, 55, -1, -59, 3, 92, -85, 4, 42, -105, 101, -117, 38, 23, -60, -119, 88, -111, -33, -66, -48, -59, -64, -51, 9, -35, -79, -12, 32, -123, 75, -107, 42, -88, -68, 82, -6, -88, -21, 122, 26, -108, 19, -1, 119, -5, -120, 95, 112, 88, 51, 112, -49, -94, 63, 51, -38, 15, -68, -25, 81, -69, -15, -87, -16, -84, 36, 107, -88, 43, -105, 78, -3, -11, -128, -37, -112, 7, -59, 25, 110, 32, -11, -79, -58, -34, -53, 12, -9, -32, -14, -7, -113, -37, 90, 126, 78, -112, -51, 52, 4, 95, -4, -45, 116, -62, -15, -37, -125, 62, 20, -90, 104, 72, -96, -61, -127, 6, 92, -77, 100, -32, -15, -76, -85, 92, -128, -107, 100, 71, 90, -96, 108, -121, -38, 110, 95, 99, 24, -70, 112, -84, 12, -35, 84, 46, 63, -66, -35, 12, 29, -6, -4, 11, -52, -119, -70, 12, -109, 103, 4, -128, -103, 70, 116, 17, 126, -113, -121, -66, 64, -3, 11, 59, -84, 74, 40, 11, -48, 43, -30, -27, -36, -16, 121, -54, 126, -12, -113, -10, 70, 96, 80, -108, 39, 85, -80, -51, 32, 85, -14, -117, -54, 115, -94, 68, -46, -120, 27, 48, 43, -15, 31, -72, 96, -68, -70, 109, -43, -37, 61, 71, -88, 88, 44, -113, -58, 86, 6, -73, -86, 20, -94, 39, 101, 113, -128, -105, 26, -124, -101, -124, 67, -106, -98, 6, 54, -91, -51, -28, 80, 107, -106, 86, 108, 44, 127, -9, -51, 112, -56, 47, 3, 95, -50, -60, -50, -116, 70, -34, 59, 97, -30, -77, -94, 118, -27, -104, 70, 84, 36, 72, 69, 22, -97, -88, -102, 74, -10, -29, 48, 57, 50, 126, 117, 34, -48, 78, -12, 17, 52, -82, -36, -22, 113, -90, -75, 26, 56, 25, 88, 54, -37, 95, -77, 52, -38, 21, -32, 43, 2, 58, -48, 118, 118, -108, 101, 64, 30, -1, -74, -44, 76, -45, 92, -64, -46, 84, -22, -110, 20, 31, -44, -128, -47, -35, 2, -119, -7, 50, 49, 108, -107, 107, -65, 12, 83, 65, -106, 16, 29, 77, -100, 49, 122, 49, -112, 111, 29, -38, 79, 63, -69, -41, 54, 26, -38, -120, 60, 43, 88, 80, -1, 72, 13, 79, -31, -47, 20, -115, -124, -73, -56, 123, 28, -28, -72, -101, 70, 91, 40, -43, -42, -75, -47, 17, -106, -86, -90, -9, -38, -114, 87, 84, 12, 96, -83, -97, 99, 83, 22, 118, -66, 108, -126, 60, -101, 90, 102, 105, 127, 71, 127, 81, 23, -102, -114, -43, 32, -108, -64, -66, -5, 57, -67, 17, 73, 51, -64, 28, 17, -19, -7, 118, 6, -5, -67, -74, -54, -113, -111, 118, 19, 87, -30, 92, -24, 105, -126, 22, 117, 21, 103, -35, -54, 65, 119, 116, -24, 101, -128, -91, 111, 62, -63, 61, 77, -110, 8, -114, 4, -40, 10, 79, 100, 34, -127, 38, -33, 96, 71, -101, 108, 70, -112, 99, 112, -61, -63, 40, -93, -21, -104, -118, 68, 77, -87, -105, -66, -37, -82, -115, 108, 4, 58, -112, -91, 35, -57, 1, -115, 97, 16, -104, -55, -14, 69, 87, -20, 41, -113, -92, 10, 87, -91, 103, 45, 81, 118, 70, 104, 108, -44, -45, 55, 0, -111, 107, 80, 46, -80, 127, -114, -19, -6, 109, 31, -112, 69, 57, 122, -108, 33, 64, 117, 115, 5, -108, -35, 70, -65, 106, -8, -64, 11, 110, 111, 74, 82, 112, 112, 59, -57, -70, -67, -107, -57, -46, 5, 24, 63, -90, -47, -22, 70, -107, 90, 17, 13, -68, 85, -23, -41, -26, -125, -83, 49, -5, -74, 111, 55, 120, 15, -15, -105, -99, -64, -72, -4, -51, 114, 80, 107, 63, 22, -53, -109, -76, -32, 25, 99, -61, -47, -41, 76, 57, -42, 104, -67, -111, 114, -109, -99, -71, -42, 10, 61, 103, 116, 64, -37, 51, -68, -31, -48, 89, -99, 11, -6, 42, 125, -123, 81, 115, -124, 90, 81, -117, -32, -88, -30, -86, -51, -49, 60, 57, -75, -25, 105, -2, -39, -49, -79, -53, -28, -23, -105, 5, 12, 71, 58, 17, 114, 70, -12, -128, 119, 101, 105, -32, -122, 56, -25, 19, 10, 15, -73, 12, 65, 22, 1, -44, 41, 67, 122, -69, -23, 80, 79, 90, -100, -93, -77, 125, 102, -102, 99, -82, -93, 92, -4, 57, -100, -84, 121, 118, 72, -124, -23, 110, 56, 75, 8, -63, 70, 78, -5, -32, -11, 110, -43, -112, -102, 115, 17, 103, -117, -48, -88, 23, -57, 89, -47, -15, -14, 76, -68, -76, -80, -21, -39, 58, 32, 71, -23, 11, 24, -51, -10, -88, -1, 97, -7, 43, 126, -45, -91, -48, 48, -53, 38, -95, -1, 69, -22, -17, 76, 58, 108, 39, 11, 127, 43, -75, 126, -24, -72, -9, -28, -31, -64, 93, -74, -96, 57, -42, 4, 37, -89, -109, 58, -92, -18, 59, -77, -82, -66, 69, -79, -120, 96, 45, 73, -32, 110, 53, 115, -69, 27, -47, -21, -50, -45, -65, -45, 95, 117, 121, -18, -2, 54, 75, -72, -89, -81, 87, -67, -66, 80, -100, 48, -29, -52, 56, -100, 21, -102, -122, -9, 60, 83, -40, 127, -76, -122, 7, 6, 6, -38, 119, -102, -27, -65, -76, -3, -63, -90, 123, 6, 20, 125, 69, -25)
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveForegroundService.kt b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveForegroundService.kt
new file mode 100644
index 0000000..a043b7c
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveForegroundService.kt
@@ -0,0 +1,75 @@
+package com.demo.SellyCloudSDK.interactive
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import com.demo.SellyCloudSDK.R
+
+/**
+ * 简单的前台服务,用于在后台保持互动通话的摄像头/麦克风存活。
+ * 只负责展示常驻通知,不绑定业务逻辑。
+ */
+class InteractiveForegroundService : Service() {
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ startForeground(NOTIFICATION_ID, buildNotification())
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?) = null
+
+ override fun onDestroy() {
+ try {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } catch (_: Exception) {
+ }
+ super.onDestroy()
+ }
+
+ private fun buildNotification(): Notification {
+ ensureChannel()
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.interactive_live_title))
+ .setContentText(getString(R.string.call_status_connected))
+ .setSmallIcon(android.R.drawable.presence_video_online)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .build()
+ }
+
+ private fun ensureChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(NotificationManager::class.java) ?: return
+ val existing = manager.getNotificationChannel(CHANNEL_ID)
+ if (existing == null) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Interactive Call",
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "interactive_call_foreground"
+ private const val NOTIFICATION_ID = 0x101
+
+ fun start(context: Context) {
+ val intent = Intent(context, InteractiveForegroundService::class.java)
+ ContextCompat.startForegroundService(context, intent)
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, InteractiveForegroundService::class.java)
+ context.stopService(intent)
+ }
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt
new file mode 100644
index 0000000..e2a3ffa
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt
@@ -0,0 +1,812 @@
+package com.demo.SellyCloudSDK.interactive
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.view.inputmethod.InputMethodManager
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import com.demo.SellyCloudSDK.R
+import com.demo.SellyCloudSDK.beauty.FURenderer
+import com.demo.SellyCloudSDK.beauty.FuVideoFrameInterceptor
+import com.demo.SellyCloudSDK.databinding.ActivityInteractiveLiveBinding
+import com.sellycloud.sellycloudsdk.interactive.CallType
+import com.sellycloud.sellycloudsdk.interactive.InteractiveCallConfig
+import com.sellycloud.sellycloudsdk.interactive.InteractiveChannelMediaOptions
+import com.sellycloud.sellycloudsdk.interactive.InteractiveConnectionState
+import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngine
+import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngine.ConnectionReason
+import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngineEventHandler
+import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngineConfig
+import com.sellycloud.sellycloudsdk.interactive.InteractiveStreamStats
+import com.sellycloud.sellycloudsdk.interactive.InteractiveVideoCanvas
+import com.sellycloud.sellycloudsdk.interactive.InteractiveVideoEncoderConfig
+import com.sellycloud.sellycloudsdk.interactive.RemoteState
+import org.webrtc.SurfaceViewRenderer
+
+class InteractiveLiveActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityInteractiveLiveBinding
+
+ private var rtcEngine: InteractiveRtcEngine? = null
+ private var localRenderer: SurfaceViewRenderer? = null
+ private lateinit var localSlot: VideoSlot
+ private lateinit var remoteSlots: List
+ private val remoteRendererMap = mutableMapOf()
+ private var isLocalPreviewEnabled = true
+ private var isLocalAudioEnabled = true
+ private var isSpeakerOn = true
+ private var localStats: InteractiveStreamStats? = null
+ private val remoteStats = mutableMapOf()
+ private var currentUserId: String? = null
+ private val defaultTokenTtlSeconds = InteractiveCallConfig.DEFAULT_TOKEN_TTL_SECONDS
+ private var currentConnectionState: InteractiveConnectionState = InteractiveConnectionState.Disconnected
+ private var callDurationSeconds: Long = 0
+ private var lastMessage: String? = null
+ private var beautyRenderer: FURenderer? = null
+ private var fuFrameInterceptor: FuVideoFrameInterceptor? = null
+ @Volatile private var isFrontCamera = true
+ @Volatile private var beautyEnabled: Boolean = true
+ @Volatile private var isLocalVideoEnabled: Boolean = true
+ private val remoteMediaState = mutableMapOf()
+
+ private val requiredPermissions = arrayOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO
+ )
+
+ private var pendingJoinRequest: JoinRequest? = null
+ private var currentCallId: String? = null
+ @Volatile private var selfUserId: String? = null
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { results ->
+ val granted = requiredPermissions.all { results[it] == true }
+ val pending = pendingJoinRequest
+ if (granted && pending != null) {
+ executeJoin(pending)
+ } else if (!granted) {
+ Toast.makeText(this, R.string.permission_required, Toast.LENGTH_LONG).show()
+ }
+ pendingJoinRequest = null
+ if (!granted) setJoinButtonEnabled(true)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityInteractiveLiveBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ supportActionBar?.apply {
+ title = getString(R.string.interactive_live_title)
+ setDisplayHomeAsUpEnabled(true)
+ }
+
+ setupVideoSlots()
+ initRtcEngine()
+ setupUiDefaults()
+ setupControlButtons()
+
+ binding.btnJoin.setOnClickListener {
+ if (currentCallId == null) {
+ attemptJoin()
+ } else {
+ leaveChannel()
+ }
+ }
+
+ binding.btnSwitchCamera.setOnClickListener {
+ isFrontCamera = !isFrontCamera
+ fuFrameInterceptor?.setFrontCamera(isFrontCamera)
+ rtcEngine?.switchCamera()
+ }
+ binding.btnToggleBeauty.setOnClickListener {
+ beautyEnabled = !beautyEnabled
+ fuFrameInterceptor?.setEnabled(beautyEnabled)
+ updateControlButtons()
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ rtcEngine?.setCaptureVideoFrameInterceptor(null)
+ leaveChannel()
+ InteractiveRtcEngine.destroy(rtcEngine)
+ rtcEngine = null
+ localRenderer?.let { releaseRenderer(it) }
+ remoteRendererMap.values.forEach { releaseRenderer(it) }
+ remoteRendererMap.clear()
+ fuFrameInterceptor = null
+ try { beautyRenderer?.release() } catch (_: Exception) {}
+ beautyRenderer = null
+ remoteMediaState.clear()
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ onBackPressedDispatcher.onBackPressed()
+ return true
+ }
+
+ private fun initRtcEngine() {
+ val appId = getString(R.string.signaling_app_id)
+ val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() }
+ beautyRenderer = FURenderer(this).also { it.setup() }
+ fuFrameInterceptor = beautyRenderer?.let { FuVideoFrameInterceptor(it).apply {
+ setFrontCamera(isFrontCamera)
+ setEnabled(beautyEnabled)
+ } }
+ rtcEngine = InteractiveRtcEngine.create(
+ InteractiveRtcEngineConfig(
+ context = applicationContext,
+ appId = appId,
+ defaultToken = token
+ )
+ ).apply {
+ setEventHandler(rtcEventHandler)
+ setClientRole(InteractiveRtcEngine.ClientRole.BROADCASTER)
+// setVideoEncoderConfiguration(InteractiveVideoEncoderConfig()) 使用默认值
+ setVideoEncoderConfiguration(InteractiveVideoEncoderConfig(640, 480 , fps = 20, minBitrateKbps = 150, maxBitrateKbps = 350))
+ setDefaultAudioRoutetoSpeakerphone(true)
+ setCaptureVideoFrameInterceptor { frame ->
+ if (!beautyEnabled) return@setCaptureVideoFrameInterceptor frame
+ fuFrameInterceptor?.process(frame) ?: frame
+ }
+ }
+ }
+
+ private val rtcEventHandler = object : InteractiveRtcEngineEventHandler {
+ override fun onJoinChannelSuccess(channel: String, userId: String, code: Int) {
+ runOnUiThread {
+ currentCallId = channel
+ currentUserId = userId
+ currentConnectionState = InteractiveConnectionState.Connected
+ callDurationSeconds = 0
+ updateLocalTileUserId(userId)
+ binding.btnJoin.text = getString(R.string.leave)
+ setJoinButtonEnabled(true)
+ updateLocalStatsLabel()
+ binding.videoContainer.isVisible = true
+ updateCallInfo()
+ }
+ }
+
+ override fun onLeaveChannel(durationSeconds: Int) {
+ Log.d(TAG, "回调onLeaveChannel duration=${durationSeconds}s")
+ runOnUiThread {
+ resetUiAfterLeave()
+ }
+ }
+
+ override fun onUserJoined(userId: String, code: Int) {
+ runOnUiThread {
+ addRemoteTile(userId)
+ Toast.makeText(
+ this@InteractiveLiveActivity,
+ "用户 ${displayId(userId)} 加入频道",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ override fun onUserLeave(userId: String, code: Int) {
+ //弹窗提示根据 code 做不同处理 0- QUIT, 1 TIMEOUT
+ Toast.makeText(
+ this@InteractiveLiveActivity,
+ "用户 ${displayId(userId)} 离开频道,原因: ${if (code == 0) "主动退出" else "超时"}",
+ Toast.LENGTH_LONG
+ ).show()
+ runOnUiThread {
+ removeRemoteTile(userId)
+ }
+ remoteMediaState.remove(displayId(userId))
+ }
+
+ override fun onConnectionStateChanged(state: InteractiveConnectionState, reason: Int, userId: String?) {
+ currentConnectionState = state
+ Log.d(
+ TAG,
+ "回调onConnectionStateChanged state=$state reason=${reasonToString(reason)} userId=${userId ?: "unknown"}"
+ )
+ runOnUiThread { updateCallInfo() }
+ }
+
+ override fun onError(code: String, message: String) {
+ Log.e(TAG, "onError code=$code message=$message")
+ runOnUiThread {
+ currentConnectionState = InteractiveConnectionState.Failed
+ updateCallInfo()
+ Toast.makeText(this@InteractiveLiveActivity, "$code: $message", Toast.LENGTH_LONG).show()
+ setJoinButtonEnabled(true)
+ if (binding.btnJoin.text == getString(R.string.join)) {
+ currentCallId = null
+ setJoinInputsVisible(true)
+ }
+ }
+ }
+
+ override fun onLocalVideoStats(stats: InteractiveStreamStats) {
+ localStats = stats
+ runOnUiThread { updateLocalStatsLabel() }
+ }
+
+ override fun onRemoteVideoStats(stats: InteractiveStreamStats) {
+ remoteStats[stats.userId] = stats
+ runOnUiThread { updateRemoteStatsLabel(stats.userId) }
+ }
+
+ override fun onMessageReceived(message: String, userId: String?) {
+ lastMessage = "${userId ?: "远端"}: $message"
+ runOnUiThread {
+ binding.tvMessageLog.text = lastMessage
+ }
+ }
+
+ override fun onTokenWillExpire(token: String?, expiresAt: Long) {
+ runOnUiThread {
+ Toast.makeText(this@InteractiveLiveActivity, "Token 即将过期,请及时续期", Toast.LENGTH_LONG).show()
+ }
+ }
+
+ override fun onTokenExpired(token: String?, expiresAt: Long) {
+ runOnUiThread {
+ Toast.makeText(this@InteractiveLiveActivity, "Token 已过期,断线后将无法重连", Toast.LENGTH_LONG).show()
+ }
+ }
+
+ override fun onDuration(durationSeconds: Long) {
+ callDurationSeconds = durationSeconds
+ runOnUiThread { updateCallInfo() }
+ }
+
+ override fun onRemoteVideoEnabled(enabled: Boolean, userId: String?) {
+ runOnUiThread { handleRemoteVideoState(enabled, userId) }
+ }
+
+ override fun onRemoteAudioEnabled(enabled: Boolean, userId: String?) {
+ runOnUiThread { handleRemoteAudioState(enabled, userId) }
+ }
+
+ override fun onStreamStateChanged(peerId: String, state: RemoteState, code: Int, message: String?) {
+ runOnUiThread {
+ val tip = "onStreamStateChanged[$peerId] state=$state code=$code ${message ?: ""}"
+ Log.d(TAG, tip)
+ Toast.makeText(this@InteractiveLiveActivity, tip, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun setupVideoSlots() {
+ localSlot = VideoSlot(binding.flLocal, TileType.LOCAL)
+ remoteSlots = listOf(
+ VideoSlot(binding.flRemote1, TileType.REMOTE),
+ VideoSlot(binding.flRemote2, TileType.REMOTE),
+ VideoSlot(binding.flRemote3, TileType.REMOTE)
+ )
+ if (localRenderer == null) {
+ localRenderer = createRenderer()
+ }
+ localRenderer?.let { renderer ->
+ localSlot.layout.attachRenderer(renderer)
+ }
+ resetVideoSlots(releaseRemotes = false)
+ binding.videoContainer.isVisible = false
+ }
+
+ private fun setupUiDefaults() {
+ binding.etCallId.setText(getString(R.string.default_call_id))
+ val defaultUser = String.format(
+ getString(R.string.default_user_id),
+ System.currentTimeMillis().toString().takeLast(4)
+ )
+ binding.etUserId.setText(defaultUser)
+ binding.rbCallTypeP2p.isChecked = true
+ isLocalPreviewEnabled = true
+ isLocalAudioEnabled = true
+ isSpeakerOn = true
+ currentConnectionState = InteractiveConnectionState.Disconnected
+ callDurationSeconds = 0
+ binding.tvCallInfo.text = getString(R.string.call_status_idle)
+ binding.tvMessageLog.text = getString(R.string.message_none)
+ setJoinInputsVisible(true)
+ }
+
+ private fun setupControlButtons() {
+ binding.btnToggleLocalPublish.isVisible = false
+ binding.btnToggleLocalPreview.setOnClickListener {
+ isLocalPreviewEnabled = !isLocalPreviewEnabled
+ applyLocalPreviewVisibility()
+ updateControlButtons()
+ }
+ binding.btnToggleMic.setOnClickListener {
+ isLocalAudioEnabled = !isLocalAudioEnabled
+ rtcEngine?.enableLocalAudio(isLocalAudioEnabled)
+ updateControlButtons()
+ }
+ binding.btnToggleCamera.setOnClickListener {
+ isLocalVideoEnabled = !isLocalVideoEnabled
+ rtcEngine?.enableLocalVideo(isLocalVideoEnabled)
+ isLocalPreviewEnabled = isLocalVideoEnabled
+ updateControlButtons()
+ }
+ binding.btnToggleAudioRoute.setOnClickListener {
+ isSpeakerOn = !isSpeakerOn
+ rtcEngine?.setDefaultAudioRoutetoSpeakerphone(isSpeakerOn)
+ updateControlButtons()
+ }
+ binding.btnSendMessage.setOnClickListener {
+ val text = binding.etMessage.text?.toString()?.trim().orEmpty()
+ if (text.isEmpty()) {
+ Toast.makeText(this, "请输入消息内容", Toast.LENGTH_SHORT).show()
+ } else if (currentCallId == null) {
+ Toast.makeText(this, "请先加入频道", Toast.LENGTH_SHORT).show()
+ } else {
+ rtcEngine?.sendMessage(text) { error ->
+ runOnUiThread {
+ if (error != null) {
+ Toast.makeText(this, "发送失败: ${error.message}", Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(this, "已发送", Toast.LENGTH_SHORT).show()
+ binding.etMessage.text?.clear()
+ lastMessage = "我: $text"
+ binding.tvMessageLog.text = lastMessage
+ }
+ }
+ }
+ }
+ }
+ updateControlButtons()
+ }
+
+ private fun updateControlButtons() {
+ binding.btnToggleLocalPreview.text = if (isLocalPreviewEnabled) {
+ getString(R.string.ctrl_local_preview_off)
+ } else {
+ getString(R.string.ctrl_local_preview_on)
+ }
+ binding.btnToggleMic.text = if (isLocalAudioEnabled) {
+ getString(R.string.ctrl_mic_off)
+ } else {
+ getString(R.string.ctrl_mic_on)
+ }
+ binding.btnToggleAudioRoute.text = if (isSpeakerOn) {
+ getString(R.string.ctrl_audio_speaker)
+ } else {
+ getString(R.string.ctrl_audio_earpiece)
+ }
+ binding.btnToggleBeauty.text = if (beautyEnabled) {
+ getString(R.string.ctrl_beauty_off)
+ } else {
+ getString(R.string.ctrl_beauty_on)
+ }
+
+ binding.btnToggleCamera.text = if (isLocalVideoEnabled) {
+ getString(R.string.ctrl_camera_off)
+ } else {
+ getString(R.string.ctrl_camera_on)
+ }
+ }
+
+ private fun applyLocalPreviewVisibility() {
+ val renderer = localRenderer ?: createRenderer().also { localRenderer = it }
+ if (isLocalPreviewEnabled) {
+ localSlot.layout.attachRenderer(renderer)
+ } else {
+ localSlot.layout.detachRenderer()
+ }
+ updateLocalStatsLabel()
+ }
+
+ private fun attemptJoin() {
+ hideKeyboard()
+ val callId = binding.etCallId.text.toString().trim()
+ if (callId.isEmpty()) {
+ Toast.makeText(this, R.string.call_id_required, Toast.LENGTH_LONG).show()
+ return
+ }
+ val userInput = binding.etUserId.text.toString().trim()
+ if (userInput.isEmpty()) {
+ Toast.makeText(this, R.string.user_id_required, Toast.LENGTH_LONG).show()
+ return
+ }
+ val appId = getString(R.string.signaling_app_id)
+ if (appId.isBlank()) {
+ Toast.makeText(this, R.string.signaling_app_id_missing, Toast.LENGTH_LONG).show()
+ return
+ }
+ val options = InteractiveChannelMediaOptions(
+ callType = if (binding.rbCallTypeP2p.isChecked) CallType.ONE_TO_ONE else CallType.GROUP
+ )
+ val tokenBundle = buildToken(appId, callId, userInput) ?: return
+ pendingJoinRequest = JoinRequest(
+ token = tokenBundle.token,
+ callId = callId,
+ userId = userInput,
+ options = options,
+ tokenExpiresAtSec = tokenBundle.expiresAtSec,
+ tokenSecret = tokenBundle.secret,
+ tokenTtlSeconds = defaultTokenTtlSeconds
+ )
+ selfUserId = userInput
+ currentConnectionState = InteractiveConnectionState.Connecting
+ updateCallInfo()
+ if (requiredPermissions.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }) {
+ executeJoin(pendingJoinRequest!!)
+ pendingJoinRequest = null
+ } else {
+ permissionLauncher.launch(requiredPermissions)
+ }
+ }
+
+ private fun buildToken(appId: String, callId: String, userId: String): TokenBundle? {
+ val manualToken = getString(R.string.signaling_token).takeIf { it.isNotBlank() }
+ if (manualToken != null) {
+ return TokenBundle(
+ token = manualToken,
+ expiresAtSec = parseExprTime(manualToken),
+ secret = null
+ )
+ }
+ val secret = getString(R.string.signaling_secret)
+ if (secret.isBlank()) {
+ Toast.makeText(this, "请在 strings.xml 配置 signaling_secret 用于生成 token,或直接填写 signaling_token", Toast.LENGTH_LONG).show()
+ return null
+ }
+ return try {
+ val generated = TokenGenerator.generate(appId, userId, callId, secret, defaultTokenTtlSeconds)
+ TokenBundle(
+ token = generated.token,
+ expiresAtSec = generated.expiresAtSec,
+ secret = secret
+ )
+ } catch (t: Throwable) {
+ Toast.makeText(this, "生成 token 失败: ${t.message}", Toast.LENGTH_LONG).show()
+ null
+ }
+ }
+
+ private fun parseExprTime(token: String): Long? {
+ return try {
+ token.split("&").firstOrNull { it.startsWith("exprtime=") }
+ ?.substringAfter("exprtime=")
+ ?.toLongOrNull()
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun executeJoin(request: JoinRequest) {
+ pendingJoinRequest = null
+ InteractiveForegroundService.start(this)
+ val renderer = localRenderer ?: createRenderer().also {
+ localRenderer = it
+ }
+ currentUserId = request.userId
+ rtcEngine?.setupLocalVideo(InteractiveVideoCanvas(renderer, request.userId))
+ ensureBeautySessionReady()
+ rtcEngine?.joinChannel(
+ request.token,
+ request.callId,
+ request.userId,
+ request.options,
+ request.tokenSecret,
+ request.tokenExpiresAtSec,
+ request.tokenTtlSeconds
+ )
+ currentCallId = request.callId
+ resetVideoSlots()
+ setJoinButtonEnabled(false)
+ setJoinInputsVisible(false)
+ updateLocalStatsLabel()
+ }
+
+ private fun ensureBeautySessionReady() {
+ try {
+ beautyRenderer?.releaseGlContext()
+ beautyRenderer?.reinitializeGlContext()
+ fuFrameInterceptor?.setEnabled(beautyEnabled)
+ fuFrameInterceptor?.setFrontCamera(isFrontCamera)
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun handleRemoteAudioState(enabled: Boolean, userId: String?) {
+ val key = userId ?: return
+ if (key == selfUserId) return
+ val state = remoteMediaState.getOrPut(key) { MediaState() }
+ if (state.audio != enabled) {
+ state.audio = enabled
+ Toast.makeText(
+ this@InteractiveLiveActivity,
+ "$key 音频${if (enabled) "打开" else "关闭"}",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ private fun handleRemoteVideoState(enabled: Boolean, userId: String?) {
+ val key = userId ?: return
+ if (key == selfUserId) return
+ val state = remoteMediaState.getOrPut(key) { MediaState() }
+ if (state.video != enabled) {
+ state.video = enabled
+ Toast.makeText(
+ this@InteractiveLiveActivity,
+ "$key 视频${if (enabled) "打开" else "关闭"}",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ private fun addRemoteTile(userId: String) {
+ remoteSlots.firstOrNull { it.userId == userId }?.let { existing ->
+ val renderer = ensureRemoteRenderer(userId)
+ existing.layout.attachRenderer(renderer)
+ remoteSlots.filter { it.userId == userId && it !== existing }.forEach { extra ->
+ extra.userId = null
+ extra.layout.detachRenderer()
+ updateSlotOverlay(extra)
+ }
+ updateSlotOverlay(existing)
+ binding.videoContainer.isVisible = true
+ return
+ }
+
+ val slot = remoteSlots.firstOrNull { it.userId == null }
+ if (slot == null) {
+ Toast.makeText(this, "Maximum remote views reached", Toast.LENGTH_SHORT).show()
+ return
+ }
+ slot.userId = userId
+ val renderer = ensureRemoteRenderer(userId)
+ slot.layout.attachRenderer(renderer)
+ updateSlotOverlay(slot)
+ binding.videoContainer.isVisible = true
+ }
+
+ private fun ensureRemoteRenderer(userId: String): SurfaceViewRenderer {
+ return remoteRendererMap[userId] ?: createRenderer().also { renderer ->
+ remoteRendererMap[userId] = renderer
+ rtcEngine?.setupRemoteVideo(InteractiveVideoCanvas(renderer, userId))
+ }
+ }
+
+ private fun removeRemoteTile(userId: String) {
+ val slot = remoteSlots.firstOrNull { it.userId == userId }
+ if (slot != null) {
+ slot.userId = null
+ slot.layout.detachRenderer()
+ updateSlotOverlay(slot)
+ }
+ rtcEngine?.clearRemoteVideo(userId)
+ remoteRendererMap.remove(userId)?.let { releaseRenderer(it) }
+ remoteStats.remove(userId)
+ }
+
+ private fun resetVideoSlots(releaseRemotes: Boolean = true) {
+ if (releaseRemotes) {
+ val remoteIds = remoteRendererMap.keys.toList()
+ remoteIds.forEach { userId ->
+ rtcEngine?.clearRemoteVideo(userId)
+ remoteRendererMap.remove(userId)?.let { releaseRenderer(it) }
+ }
+ remoteStats.clear()
+ }
+ remoteSlots.forEach { slot ->
+ slot.userId = null
+ slot.layout.detachRenderer()
+ updateSlotOverlay(slot)
+ }
+ localSlot.userId = currentUserId
+ val renderer = localRenderer ?: createRenderer().also { localRenderer = it }
+ if (isLocalPreviewEnabled) {
+ localSlot.layout.attachRenderer(renderer)
+ } else {
+ localSlot.layout.detachRenderer()
+ }
+ updateSlotOverlay(localSlot)
+ }
+
+ private fun updateLocalTileUserId(userId: String?) {
+ localSlot.userId = userId
+ updateSlotOverlay(localSlot)
+ }
+
+ private fun displayId(userId: String): String = userId
+
+ private fun leaveChannel() {
+ rtcEngine?.leaveChannel()
+ resetUiAfterLeave()
+ }
+
+ private fun resetUiAfterLeave() {
+ currentCallId = null
+ resetVideoSlots()
+ binding.videoContainer.isVisible = false
+ binding.btnJoin.text = getString(R.string.join)
+ setJoinButtonEnabled(true)
+ isLocalPreviewEnabled = true
+ isLocalAudioEnabled = true
+ isSpeakerOn = true
+ beautyEnabled = true
+ fuFrameInterceptor?.setEnabled(true)
+ selfUserId = null
+ localStats = null
+ remoteStats.clear()
+ currentUserId = null
+ currentConnectionState = InteractiveConnectionState.Disconnected
+ callDurationSeconds = 0
+ lastMessage = null
+ binding.tvMessageLog.text = getString(R.string.message_none)
+ updateControlButtons()
+ updateLocalStatsLabel()
+ updateCallInfo()
+ setJoinInputsVisible(true)
+ InteractiveForegroundService.stop(this)
+ }
+
+ private fun createRenderer(): SurfaceViewRenderer = SurfaceViewRenderer(this).apply {
+ setZOrderMediaOverlay(false)
+ }
+
+ private fun releaseRenderer(renderer: SurfaceViewRenderer) {
+ try {
+ renderer.release()
+ } catch (_: Exception) {}
+ }
+
+ private fun hideKeyboard() {
+ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ imm?.hideSoftInputFromWindow(binding.root.windowToken, 0)
+ }
+
+ private fun setJoinButtonEnabled(enabled: Boolean) {
+ binding.btnJoin.isEnabled = enabled
+ }
+
+ private fun setJoinInputsVisible(visible: Boolean) {
+ binding.etCallId.isVisible = visible
+ binding.etUserId.isVisible = visible
+ binding.callTypeGroup.isVisible = visible
+ }
+
+ private fun updateLocalStatsLabel() {
+ updateSlotOverlay(localSlot)
+ }
+
+ private fun updateRemoteStatsLabel(userId: String) {
+ remoteSlots.firstOrNull { it.userId == userId }?.let { updateSlotOverlay(it) }
+ }
+
+ private fun updateSlotOverlay(slot: VideoSlot) {
+ val stats = when (slot.type) {
+ TileType.LOCAL -> localStats
+ TileType.REMOTE -> slot.userId?.let { remoteStats[it] }
+ }
+ if (!slot.layout.hasVideo() || stats == null) {
+ slot.layout.hideOverlay()
+ return
+ }
+ val header = when {
+ slot.userId != null -> "ID: ${displayId(slot.userId!!)}"
+ slot.type == TileType.LOCAL -> "本地"
+ else -> getString(R.string.user_id)
+ }
+ val text = buildStatsLabel(header, stats)
+ slot.layout.updateOverlayText(text)
+ }
+
+ private fun updateCallInfo() {
+ val stateText = when (currentConnectionState) {
+ InteractiveConnectionState.Connecting -> getString(R.string.call_status_connecting)
+ InteractiveConnectionState.Connected -> getString(R.string.call_status_connected)
+ InteractiveConnectionState.Reconnecting -> getString(R.string.call_status_reconnecting)
+ InteractiveConnectionState.Failed -> getString(R.string.call_status_failed)
+ else -> getString(R.string.call_status_idle)
+ }
+ val duration = if (callDurationSeconds > 0) {
+ val minutes = callDurationSeconds / 60
+ val seconds = callDurationSeconds % 60
+ String.format(" | 时长 %02d:%02d", minutes, seconds)
+ } else {
+ ""
+ }
+ binding.tvCallInfo.text = stateText + duration
+ }
+
+ private fun buildStatsLabel(header: String, stats: InteractiveStreamStats?): String {
+ val lines = mutableListOf(header)
+ val width = stats?.width?.takeIf { it > 0 }?.toString() ?: "--"
+ val height = stats?.height?.takeIf { it > 0 }?.toString() ?: "--"
+ val fpsText = stats?.fps?.takeIf { it > 0 }?.let { String.format("%.1f fps", it.toDouble()) } ?: "-- fps"
+ lines += "Res:${width}x${height} $fpsText"
+ val videoCodec = stats?.videoCodec?.takeIf { it.isNotBlank() }
+ val audioCodec = stats?.audioCodec?.takeIf { it.isNotBlank() }
+ val codecLine = when {
+ videoCodec != null && audioCodec != null -> "$videoCodec@$audioCodec"
+ videoCodec != null -> videoCodec
+ audioCodec != null -> audioCodec
+ else -> null
+ }
+ codecLine?.let { lines += it }
+ val videoBitrate = stats?.videoBitrateKbps?.takeIf { it > 0 }?.let { String.format("%.0f", it.toDouble()) } ?: "--"
+ val audioBitrate = stats?.audioBitrateKbps?.takeIf { it > 0 }?.let { String.format("%.0f", it.toDouble()) } ?: "--"
+ lines += "Video:${videoBitrate}kbps Audio:${audioBitrate}kbps"
+ val rtt = stats?.rttMs?.takeIf { it > 0 }?.let { String.format("%.0fms", it.toDouble()) } ?: "--"
+ lines += "RTT:$rtt"
+ return lines.joinToString("\n")
+ }
+
+ /**
+ * 按用户静音/取消静音远端音频的示例。
+ *
+ * @param targetUserId 远端用户 ID
+ * @param muted true 表示静音该用户,false 取消静音
+ */
+ private fun muteRemoteUserAudio(targetUserId: String, muted: Boolean) {
+ rtcEngine?.muteRemoteAudioStream(targetUserId, muted)
+ }
+
+ /**
+ * 按用户关闭/恢复远端视频渲染的示例。
+ *
+ * @param targetUserId 远端用户 ID
+ * @param muted true 表示关闭该用户的视频,false 恢复
+ */
+ private fun muteRemoteUserVideo(targetUserId: String, muted: Boolean) {
+ rtcEngine?.muteRemoteVideoStream(targetUserId, muted)
+ }
+
+ private fun reasonToString(reason: Int): String = when (reason) {
+ ConnectionReason.SIGNAL_CONNECTED -> "SIGNAL_CONNECTED"
+ ConnectionReason.SIGNAL_RETRYING -> "SIGNAL_RETRYING"
+ ConnectionReason.SIGNAL_FAILED -> "SIGNAL_FAILED"
+ ConnectionReason.ICE_RETRYING -> "ICE_RETRYING"
+ ConnectionReason.ICE_FAILED -> "ICE_FAILED"
+ ConnectionReason.CLIENT_LEAVE -> "CLIENT_LEAVE"
+ ConnectionReason.TOKEN_EXPIRED -> "TOKEN_EXPIRED"
+ ConnectionReason.SIGNAL_CONNECTING -> "SIGNAL_CONNECTING"
+ else -> "UNKNOWN($reason)"
+ }
+
+ companion object {
+ private const val TAG = "InteractiveLiveActivity"
+ }
+
+ private data class VideoSlot(
+ val layout: VideoReportLayout,
+ val type: TileType,
+ var userId: String? = null
+ )
+
+ private data class TokenBundle(
+ val token: String,
+ val expiresAtSec: Long?,
+ val secret: String?
+ )
+
+ private enum class TileType {
+ LOCAL,
+ REMOTE
+ }
+
+ private data class JoinRequest(
+ val token: String?,
+ val callId: String,
+ val userId: String,
+ val options: InteractiveChannelMediaOptions,
+ val tokenExpiresAtSec: Long?,
+ val tokenSecret: String?,
+ val tokenTtlSeconds: Long
+ )
+
+ private data class MediaState(
+ var audio: Boolean? = null,
+ var video: Boolean? = null
+ )
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/interactive/TokenGenerator.kt b/example/src/main/java/com/demo/SellyCloudSDK/interactive/TokenGenerator.kt
new file mode 100644
index 0000000..8de62ff
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/interactive/TokenGenerator.kt
@@ -0,0 +1,44 @@
+package com.demo.SellyCloudSDK.interactive
+
+import com.sellycloud.sellycloudsdk.interactive.InteractiveCallConfig
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+data class GeneratedToken(
+ val token: String,
+ val expiresAtSec: Long
+)
+
+/**
+ * Demo 侧 token 生成工具(HMAC-SHA256)
+ */
+object TokenGenerator {
+ private const val HMAC_ALGO = "HmacSHA256"
+
+ /**
+ * 生成 token 格式:
+ * appid={appId}&userid={userId}&callid={callId}&signtime={signTime}&exprtime={exprTime}&sign={hmac_sha256_hex}
+ */
+ fun generate(
+ appId: String,
+ userId: String,
+ callId: String,
+ secret: String,
+ ttlSeconds: Long = InteractiveCallConfig.DEFAULT_TOKEN_TTL_SECONDS,
+ nowSeconds: Long = System.currentTimeMillis() / 1000
+ ): GeneratedToken {
+ val signTime = nowSeconds
+ val exprTime = nowSeconds + ttlSeconds
+ val payload = "$appId$userId$callId$signTime$exprTime"
+ val sign = hmacSha256Hex(secret, payload)
+ val token = "appid=$appId&userid=$userId&callid=$callId&signtime=$signTime&exprtime=$exprTime&sign=$sign"
+ return GeneratedToken(token, exprTime)
+ }
+
+ private fun hmacSha256Hex(key: String, data: String): String {
+ val mac = Mac.getInstance(HMAC_ALGO)
+ mac.init(SecretKeySpec(key.toByteArray(), HMAC_ALGO))
+ val result = mac.doFinal(data.toByteArray())
+ return result.joinToString("") { String.format("%02x", it) }
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/interactive/VideoReportLayout.kt b/example/src/main/java/com/demo/SellyCloudSDK/interactive/VideoReportLayout.kt
new file mode 100644
index 0000000..1cfb963
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/interactive/VideoReportLayout.kt
@@ -0,0 +1,142 @@
+package com.demo.SellyCloudSDK.interactive
+
+import android.content.Context
+import android.graphics.Color
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.core.view.isVisible
+
+/**
+ * Simple container that overlays a text label over video surfaces.
+ */
+class VideoReportLayout @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+ private val overlay: TextView = TextView(context).apply {
+ setBackgroundColor(Color.parseColor("#80000000"))
+ setTextColor(Color.WHITE)
+ textSize = 12f
+ gravity = Gravity.START
+ setPadding(16, 12, 16, 12)
+ isVisible = false
+ }
+
+ var currentPeerId: String? = null
+ private set
+
+ var enforceSquare: Boolean = false
+ set(value) {
+ field = value
+ requestLayout()
+ }
+
+ init {
+ // Make container transparent by default to avoid black/dark background when no stream
+ setBackgroundColor(Color.TRANSPARENT)
+ clipToPadding = false
+ attachOverlay()
+ }
+
+ override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
+ super.addView(child, index, params)
+ overlay.bringToFront()
+ }
+
+ fun setLabel(label: String) {
+ updateOverlayText(label)
+ }
+
+ fun bindPeer(label: String, peerId: String? = null) {
+ currentPeerId = peerId
+ setLabel(label)
+ }
+
+ fun updateOverlayText(text: String) {
+ attachOverlay()
+ overlay.text = text
+ overlay.isVisible = true
+ }
+
+ fun hideOverlay() {
+ overlay.isVisible = false
+ }
+
+ fun clearPeer() {
+ currentPeerId = null
+ removeVideoSurfaces()
+ overlay.isVisible = false
+ }
+
+ fun hasVideo(): Boolean {
+ for (i in 0 until childCount) {
+ val child = getChildAt(i)
+ if (child !== overlay) return true
+ }
+ return false
+ }
+
+ fun reset() {
+ clearPeer()
+ }
+
+ fun attachRenderer(view: View) {
+ if (view.parent !== this) {
+ (view.parent as? ViewGroup)?.removeView(view)
+ }
+ removeVideoSurfaces()
+ addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
+ overlay.bringToFront()
+ }
+
+ fun detachRenderer() {
+ removeVideoSurfaces()
+ overlay.bringToFront()
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ if (enforceSquare) {
+ val widthSize = View.MeasureSpec.getSize(widthMeasureSpec)
+ if (widthSize > 0) {
+ val squareHeightSpec = View.MeasureSpec.makeMeasureSpec(widthSize, View.MeasureSpec.EXACTLY)
+ super.onMeasure(widthMeasureSpec, squareHeightSpec)
+ return
+ }
+ }
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ }
+
+ private fun removeVideoSurfaces() {
+ val toRemove = mutableListOf()
+ for (i in 0 until childCount) {
+ val child = getChildAt(i)
+ if (child !== overlay) {
+ toRemove.add(i)
+ }
+ }
+ toRemove.asReversed().forEach { index ->
+ removeViewAt(index)
+ }
+ attachOverlay()
+ }
+
+ private fun attachOverlay() {
+ val params = LayoutParams(
+ LayoutParams.WRAP_CONTENT,
+ LayoutParams.WRAP_CONTENT,
+ Gravity.TOP or Gravity.START
+ )
+ if (overlay.parent == null) {
+ addView(overlay, params)
+ } else {
+ overlay.layoutParams = params
+ overlay.bringToFront()
+ }
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/MainActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/MainActivity.kt
new file mode 100644
index 0000000..8c692f4
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/live/MainActivity.kt
@@ -0,0 +1,887 @@
+package com.demo.SellyCloudSDK.live
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.PixelFormat
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.SurfaceHolder
+import android.view.View
+import android.view.WindowManager
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import com.demo.SellyCloudSDK.R
+import com.demo.SellyCloudSDK.beauty.FaceUnityBeautyEngine
+import com.demo.SellyCloudSDK.databinding.ActivityMainBinding
+import com.sellycloud.sellycloudsdk.*
+import com.sellycloud.sellycloudsdk.PlayerConfig
+import com.sellycloud.sellycloudsdk.RtmpPlayer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.delay
+import org.webrtc.SurfaceViewRenderer
+import com.sellycloud.sellycloudsdk.Protocol
+import android.content.res.Configuration
+import org.webrtc.RendererCommon
+import kotlin.text.clear
+
+class MainActivity : AppCompatActivity(),
+ SurfaceHolder.Callback {
+
+ private lateinit var binding: ActivityMainBinding
+
+ // 单一 StreamingManager,按协议初始化
+ private var streamingManager: StreamingManager? = null
+ private val faceUnityBeautyEngine: FaceUnityBeautyEngine by lazy { FaceUnityBeautyEngine() }
+
+ // UI 状态助手
+ private lateinit var uiState: UiStateManager
+
+ // 播放 Surface 管理器
+ private lateinit var playSurfaceManager: PlaySurfaceManager
+
+ // WHEP 相关
+ private var whepClient: WhepClient? = null
+ private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private var isWhepPlaying = false
+ private var whepSurfaceView: SurfaceViewRenderer? = null
+ private var webrtcEglBase: org.webrtc.EglBase? = null
+
+ // 预览 Surface 就绪标志(RTMP 预览视图)
+ private var isPushSurfaceReady = false
+
+ // 协议选择
+ private var selectedProtocol: Protocol = Protocol.RTMP
+
+ // 播放类型枚举
+ private enum class PlayType { NONE, RTMP, WHEP }
+ private var currentPlayType = PlayType.NONE
+
+ // 播放器
+ private var player: RtmpPlayer? = null
+ private var playerConfig: PlayerConfig? = null
+ private var isPlaySurfaceValid = false
+ private var lastPlayUrl: String? = null
+ private var shouldResumePlayback = false
+ private var needRecreatePlayer = false
+
+ // 状态变量
+ private var idelStatus = "待启动"
+
+ private val permissions =
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) arrayOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ ) else arrayOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO
+ )
+
+ // 防止重复启动预览导致多次 GL / 美颜初始化
+ private var hasStartedPushPreview = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ // 初始化 UI 与管理器
+ uiState = UiStateManager(binding)
+ playSurfaceManager = PlaySurfaceManager(binding.surfaceViewPlay)
+ uiState.setRtmpButtonText(false)
+ updateWhepButtonText()
+
+ // 屏幕常亮
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ // 初始化 StreamingManager 与监听
+ streamingManager = StreamingManager(this).also { mgr ->
+ mgr.setBeautyEngine(faceUnityBeautyEngine)
+ mgr.setStreamingListener(object : StreamingListener {
+ override fun onStateUpdate(state: StreamingState, message: String?, extras: Bundle?) {
+ runOnUiThread {
+ val text = message ?: when (state) {
+ StreamingState.IDLE -> "待启动"
+ StreamingState.CONNECTING -> "连接中..."
+ StreamingState.STREAMING -> "推流中"
+ StreamingState.RECONNECTING -> "重连中..."
+ StreamingState.STOPPED -> "已停止"
+ StreamingState.FAILED -> "推流错误"
+ }
+ val logMap = mapOf(
+ "state" to state.name,
+ "message" to message,
+ "extras" to bundleToMap(extras)
+ )
+ Log.d("MainActivity111111", logMap.toString())
+ uiState.setPushStatusText(text, idelStatus)
+ uiState.setPushButtonsEnabled(state == StreamingState.STREAMING)
+ setProtocolSelectionEnabled(state != StreamingState.STREAMING && state != StreamingState.CONNECTING && state != StreamingState.RECONNECTING)
+ }
+ }
+ override fun onError(error: StreamingError) {
+ runOnUiThread { Toast.makeText(this@MainActivity, error.message, Toast.LENGTH_SHORT).show() }
+ }
+ })
+ }
+ // 默认 RTMP 预览标题
+ val defaultId = if (selectedProtocol == Protocol.WHIP) R.id.rbProtocolWhip else R.id.rbProtocolRtmp
+ if (binding.protocolGroup.checkedRadioButtonId != defaultId) {
+ binding.protocolGroup.check(defaultId)
+ }
+ setPushPreviewHeader(selectedProtocol.name)
+ // 绑定 UI 监听
+ setupListeners()
+
+ // 权限
+ checkAndRequestPermissions()
+ }
+
+ // 接管屏幕方向变化,避免 Activity 重建导致两个预览销毁
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+
+ // RTMP 推流预览(OpenGlView)保持像素格式与叠放层不变,仅请求重新布局
+ try {
+ binding.surfaceViewPlay.setZOrderMediaOverlay(false)
+ binding.surfaceViewPlay.holder.setFormat(PixelFormat.OPAQUE)
+ binding.surfaceViewPlay.requestLayout()
+ } catch (_: Exception) {}
+
+ // WHIP 推流预览(SurfaceViewRenderer)只调整缩放并请求布局
+ try {
+ binding.whipPreview.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
+ binding.whipPreview.setEnableHardwareScaler(true)
+ binding.whipPreview.requestLayout()
+ } catch (_: Exception) {}
+
+ // 若当前是 WHEP 播放,动态渲染器同样更新缩放并请求布局
+ try {
+ whepSurfaceView?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
+ whepSurfaceView?.setEnableHardwareScaler(true)
+ whepSurfaceView?.requestLayout()
+ } catch (_: Exception) {}
+
+ // 播放 Surface 保持 RGBA_8888 与覆盖层,确保颜色/叠放正确
+ try {
+ binding.surfaceViewPlay.setZOrderMediaOverlay(true)
+ ensurePlaySurfaceFormat()
+ binding.surfaceViewPlay.requestLayout()
+ } catch (_: Exception) {}
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // 恢复美颜/GL 管线
+// streamingManager?.onResume()
+ // 恢复预览(RTMP/WHIP)
+ if (isPushSurfaceReady) {
+ try {
+ streamingManager?.resumePreview()
+ } catch (_: Exception) {
+ }
+ }
+ // 播放器复位
+ if (needRecreatePlayer && isPlaySurfaceValid) {
+ recreatePlayerAndMaybeResume()
+ } else if (shouldResumePlayback && !lastPlayUrl.isNullOrEmpty()) {
+ val holder = binding.surfaceViewPlay.holder
+ if (holder.surface != null && holder.surface.isValid) {
+ ensurePlaySurfaceFormat()
+ player?.setSurface(holder.surface)
+ player?.prepareAsync(lastPlayUrl!!)
+ updateStatus(playStatus = "正在连接")
+ updatePlayButtonStates(false)
+ shouldResumePlayback = false
+ }
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ // 暂停美颜/GL 管线
+// streamingManager?.onPause()
+ // 暂停预览(RTMP/WHIP)
+ if (selectedProtocol == Protocol.RTMP) {
+ try { streamingManager?.pausePreview() } catch (_: Exception) {}
+ } else if (selectedProtocol == Protocol.WHIP) {
+ try { streamingManager?.stopWhipPreview() } catch (_: Exception) {}
+ }
+ // 播放侧资源处理
+ shouldResumePlayback = player?.isPlaying() == true || player?.isPrepared() == true
+ try { player?.setSurface(null) } catch (_: Exception) {}
+ player?.release(); player = null
+ needRecreatePlayer = true
+ shouldResumePlayback = true
+ }
+
+ private fun setupListeners() {
+ // 开始推流(根据协议)
+ binding.btnStartPush.setOnClickListener {
+ // 获取各个配置字段
+// val host = binding.etHost.text.toString().trim()
+ val appName = binding.etAppName.text.toString().trim()
+ val streamName = binding.etStreamName.text.toString().trim()
+// val streamKey = binding.etStreamKey.text.toString().trim()
+
+ // 验证必填字段
+// if (host.isEmpty()) {
+// Toast.makeText(this, "请输入Host地址", Toast.LENGTH_SHORT).show()
+// return@setOnClickListener
+// }
+ if (appName.isEmpty()) {
+ Toast.makeText(this, "请输入App Name", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ if (streamName.isEmpty()) {
+ Toast.makeText(this, "请输入Stream Name", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ streamingManager?.updateStreamConfig(
+ host = "rtmp.sellycloud.push",
+ appName = appName,
+ streamName = streamName,
+ streamKey = ""
+ )
+ streamingManager?.startStreaming()
+ // 同步美颜
+ streamingManager?.setBeautyEnabled(binding.switchBeauty.isChecked)
+ }
+ // 停止推流
+ binding.btnStopPush.setOnClickListener { streamingManager?.stopStreaming() }
+ // 协议选择监听
+ binding.protocolGroup.setOnCheckedChangeListener { _, checkedId ->
+ val newProtocol = if (checkedId == R.id.rbProtocolWhip) Protocol.WHIP else Protocol.RTMP
+ if (newProtocol != selectedProtocol) {
+ switchProtocol(newProtocol)
+ }
+ }
+ // 切换摄像头
+ binding.btnSwitchCamera.setOnClickListener { streamingManager?.switchCamera() }
+ // 切换方向
+ binding.btnSwitchOrientation.setOnClickListener { streamingManager?.switchOrientation() }
+ // 镜像
+ binding.cbPreviewHFlip.setOnCheckedChangeListener { _, h ->
+ streamingManager?.setMirror(horizontal = h, vertical = binding.cbPreviewVFlip.isChecked)
+ }
+ binding.cbPreviewVFlip.setOnCheckedChangeListener { _, v ->
+ streamingManager?.setMirror(horizontal = binding.cbPreviewHFlip.isChecked, vertical = v)
+ }
+ // 美颜
+ binding.switchBeauty.setOnCheckedChangeListener { _, on ->
+ streamingManager?.setBeautyEnabled(on)
+ Toast.makeText(this, "美颜功能${if (on) "开启" else "关闭"}", Toast.LENGTH_SHORT).show()
+ }
+ binding.switchBeauty.setOnLongClickListener { Toast.makeText(this, "高级美颜面板暂未开放", Toast.LENGTH_SHORT).show(); true }
+ // 分辨率
+ binding.resolutionGroup.setOnCheckedChangeListener { _, checkedId ->
+ val (w, h) = when (checkedId) {
+ R.id.res360p -> 360 to 640
+ R.id.res540p -> 540 to 960
+ R.id.res720p -> 720 to 1280
+ R.id.res1080p -> 1080 to 1920
+ else -> 720 to 1280
+ }
+ streamingManager?.changeResolution(w, h)
+ }
+ // 选择图片作为视频源
+ val pickImage = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
+ if (uri == null) { Toast.makeText(this, "未选择图片", Toast.LENGTH_SHORT).show(); return@registerForActivityResult }
+ try {
+ val bmp = decodeBitmapFromUri(uri)
+ if (bmp != null) {
+ val ok = streamingManager?.setBitmapAsVideoSource(bmp)
+ Toast.makeText(this, if (ok == true) "已切换为图片源" else "暂不支持该分辨率/失败", Toast.LENGTH_SHORT).show()
+ } else Toast.makeText(this, "图片解码失败", Toast.LENGTH_SHORT).show()
+ } catch (e: Exception) { Toast.makeText(this, "设置图片源失败: ${e.message}", Toast.LENGTH_LONG).show() }
+ }
+ binding.btnChooseImageSource.setOnClickListener { pickImage.launch("image/*") }
+ // 恢复摄像头视频源
+ binding.btnRestoreCamera.setOnClickListener { streamingManager?.restoreCameraVideoSource() }
+
+ // RTMP 播放:单按钮切换 开始/停止
+ binding.btnPlay.setOnClickListener {
+ if (currentPlayType == PlayType.RTMP) {
+ stopCurrentPlayback()
+ return@setOnClickListener
+ }
+ val playAppName = binding.etPlayAppName.text.toString().trim()
+ val playStreamName = binding.etPlayStreamName.text.toString().trim()
+
+ if (playAppName.isEmpty()) { Toast.makeText(this, "请输入Play App Name", Toast.LENGTH_SHORT).show(); return@setOnClickListener }
+ if (playStreamName.isEmpty()) { Toast.makeText(this, "请输入Play Stream Name", Toast.LENGTH_SHORT).show(); return@setOnClickListener }
+
+ val url = buildPlayUrl("RTMP", playAppName, playStreamName)
+ Toast.makeText(this, "播放地址: $url", Toast.LENGTH_SHORT).show()
+
+ stopCurrentPlayback() // 停止其他播放(如WHEP)
+ if (isPlaySurfaceValid) {
+ ensurePlaySurfaceFormat()
+ lastPlayUrl = url
+ currentPlayType = PlayType.RTMP
+ player?.setSurface(binding.surfaceViewPlay.holder.surface)
+ player?.prepareAsync(url)
+ updatePlayButtonStates(false)
+ uiState.setRtmpButtonText(true)
+ updateStatus(playStatus = "正在连接(RTMP)")
+ } else Toast.makeText(this, "播放 Surface 未准备好", Toast.LENGTH_SHORT).show()
+ }
+
+ // 已移除独立的停止播放按钮
+ // WHEP 拉流:单按钮切换 开始/停止
+ binding.btnWhepPlay.setOnClickListener {
+ if (isWhepPlaying) {
+ stopWhepStreaming()
+ } else {
+ val playAppName = binding.etPlayAppName.text.toString().trim()
+ val playStreamName = binding.etPlayStreamName.text.toString().trim()
+ if (playAppName.isEmpty()) { Toast.makeText(this, "请输入Play App Name", Toast.LENGTH_SHORT).show(); return@setOnClickListener }
+ if (playStreamName.isEmpty()) { Toast.makeText(this, "请输入Play Stream Name", Toast.LENGTH_SHORT).show(); return@setOnClickListener }
+ val url = buildPlayUrl("WHEP", playAppName, playStreamName)
+ Toast.makeText(this, "播放地址: $url", Toast.LENGTH_SHORT).show()
+ startWhepStreaming(url)
+ }
+ }
+
+ // 截图:推流预览
+ binding.btnCapturePush.setOnClickListener {
+ val targetView: View? = if (selectedProtocol == Protocol.WHIP) binding.whipPreview else binding.surfaceViewPush
+ captureSurfaceViewAndSave(targetView, prefix = "push")
+ }
+ // 截图:播放
+ binding.btnCapturePlay.setOnClickListener {
+ val targetView: View? = if (isWhepPlaying) whepSurfaceView else binding.surfaceViewPlay
+ captureSurfaceViewAndSave(targetView, prefix = "play")
+ }
+ }
+
+ /* ---------- 协议切换 ---------- */
+ private fun switchProtocol(newProtocol: Protocol) {
+ if (newProtocol == Protocol.RTMP) {
+ binding.surfaceViewPush.visibility = View.VISIBLE
+ binding.whipPreview.visibility = View.GONE
+ setPushPreviewHeader("RTMP")
+ } else {
+ binding.surfaceViewPush.visibility = View.GONE
+ binding.whipPreview.visibility = View.VISIBLE
+ setPushPreviewHeader("WHIP")
+ }
+
+ // 根据协议选择对应视图
+ val targetView = if (newProtocol == Protocol.RTMP) {
+ binding.surfaceViewPush
+ } else {
+ binding.whipPreview
+ }
+ selectedProtocol = newProtocol
+ streamingManager?.switchProtocol(newProtocol, targetView)
+
+ }
+
+ private fun setProtocolSelectionEnabled(enabled: Boolean) {
+ binding.protocolGroup.isEnabled = enabled
+ binding.rbProtocolRtmp.isEnabled = enabled
+ binding.rbProtocolWhip.isEnabled = enabled
+ }
+
+ /* ---------- SurfaceHolder.Callback ---------- */
+ override fun surfaceCreated(holder: SurfaceHolder) {
+ when (holder.surface) {
+ binding.surfaceViewPlay.holder.surface -> {
+ isPlaySurfaceValid = true
+ ensurePlaySurfaceFormat()
+ if (needRecreatePlayer) {
+ recreatePlayerAndMaybeResume()
+ } else {
+ player?.setSurface(holder.surface)
+ if (shouldResumePlayback && !lastPlayUrl.isNullOrEmpty()) {
+ player?.prepareAsync(lastPlayUrl!!)
+ updateStatus(playStatus = "正在连接")
+ updatePlayButtonStates(false)
+ shouldResumePlayback = false
+ }
+ }
+ }
+ binding.surfaceViewPush.holder.surface -> {
+ isPushSurfaceReady = true
+ //打印日志
+ Log.d("MainActivity", "Push surface created")
+ Log.d("MainActivity" , hasStartedPushPreview.toString())
+ // 仅首次或重建后启动预览,避免重复 startPreview + 触发多次美颜加载
+ if (!hasStartedPushPreview) {
+ try {
+ streamingManager?.startPreview(); hasStartedPushPreview = true
+ streamingManager?.setBeautyEnabled(binding.switchBeauty.isChecked)
+ } catch (_: Exception) {
+ }
+ }
+ }
+ }
+ }
+
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
+ if (holder.surface == binding.surfaceViewPlay.holder.surface) ensurePlaySurfaceFormat()
+ }
+
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ when (holder.surface) {
+ binding.surfaceViewPlay.holder.surface -> {
+ isPlaySurfaceValid = false
+ player?.setSurface(null)
+ }
+ binding.surfaceViewPush.holder.surface -> {
+ Log.d("MainActivity", "Push surface destroyed")
+ isPushSurfaceReady = false
+ hasStartedPushPreview = false // 下次重建允许重新 startPreview
+ // 释放摄像头/预览由流程统一处理
+ }
+ }
+ }
+
+ /** 权限检测与申请 */
+ private fun checkAndRequestPermissions() {
+ if (permissions.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }) {
+ setupAll()
+ } else {
+ permissionLauncher.launch(permissions)
+ }
+ }
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { results ->
+ if (results.values.all { it }) setupAll() else Toast.makeText(this, "需要相机和录音权限才能使用此功能", Toast.LENGTH_LONG).show()
+ }
+
+ /** 初始化(推流 + 播放) */
+ private fun setupAll() {
+ // 默认显示 RTMP 预览视图
+ binding.surfaceViewPush.visibility = if (selectedProtocol == Protocol.RTMP) View.VISIBLE else View.GONE
+ binding.whipPreview.visibility = if (selectedProtocol == Protocol.WHIP) View.VISIBLE else View.GONE
+
+ // RTMP 预览器配置
+ binding.surfaceViewPlay.setZOrderMediaOverlay(false)
+ binding.surfaceViewPlay.holder.setFormat(PixelFormat.OPAQUE)
+ // 由 Surface 回调驱动 RTMP 预览生命周期
+ binding.surfaceViewPush.holder.addCallback(this)
+
+ binding.whipPreview.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL )
+ binding.whipPreview.setEnableHardwareScaler(true)
+
+ val (w, h) = currentResolution()
+ //配置参数
+ streamingManager?.updateStreamConfig(
+ protocol = selectedProtocol,
+ width = 1080,
+ height = 1920,
+ fps = 40,
+ videoBitrate = 2_500_000,
+ audioBitrate = 128_000,
+ iFrameInterval = 1,
+ maxRetryCount = 5,
+ retryDelayMs = 3000,
+ facing = "front"
+ )
+
+ // 初始化 manager 与预览
+ try {
+ if (selectedProtocol == Protocol.RTMP) {
+ streamingManager?.initialize(binding.surfaceViewPush)
+ } else {
+ streamingManager?.initialize(binding.whipPreview)
+ }
+ } catch (_: Exception) {}
+
+ // 播放器与回调维持原逻辑
+ playerConfig = PlayerConfig.forRtmpLive(enableKiwi = true, rsname = "123")
+ player = RtmpPlayer(context = this, playerConfig = playerConfig!!)
+ attachRtmpPlayerStateListener()
+ binding.surfaceViewPlay.setZOrderMediaOverlay(false)
+ binding.surfaceViewPlay.holder.setFormat(PixelFormat.OPAQUE)
+ binding.surfaceViewPlay.holder.addCallback(this)
+ if (binding.surfaceViewPlay.holder.surface.isValid) {
+ surfaceCreated(binding.surfaceViewPlay.holder)
+ }
+ }
+
+ /** 将Uri解码成合适大小的Bitmap,避免OOM */
+ private fun decodeBitmapFromUri(uri: Uri): Bitmap? {
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ contentResolver.openInputStream(uri)?.use { input -> BitmapFactory.decodeStream(input, null, options) }
+ val reqMax = 1280
+ var inSample = 1
+ val w = options.outWidth; val h = options.outHeight
+ if (w > reqMax || h > reqMax) {
+ val halfW = w / 2; val halfH = h / 2
+ while ((halfW / inSample) >= reqMax || (halfH / inSample) >= reqMax) { inSample *= 2 }
+ }
+ val decodeOpts = BitmapFactory.Options().apply { inSampleSize = inSample }
+ contentResolver.openInputStream(uri)?.use { input -> return BitmapFactory.decodeStream(input, null, decodeOpts) }
+ return null
+ }
+
+ /** 更新状态文本 */
+ private fun updateStatus(pushStatus: String? = null, playStatus: String? = null) {
+ runOnUiThread {
+ val currentPushStatus = binding.tvStatus.text.split("|")[0].split(":").getOrNull(1)?.trim() ?: "待启动"
+ val newPushStatus = pushStatus ?: currentPushStatus
+ if (playStatus != null) this.idelStatus = playStatus
+ uiState.setPushStatusText(newPushStatus, this.idelStatus)
+ }
+ }
+
+ private fun updatePlayButtonStates(enabled: Boolean) { runOnUiThread { uiState.setPlayButtonEnabled(enabled) } }
+ private fun setPushPreviewHeader(mode: String) { try { uiState.setPushPreviewHeader(mode) } catch (_: Exception) {} }
+ private fun updateWhepButtonText() { uiState.setWhepButtonText(isWhepPlaying) }
+
+ private fun stopCurrentPlayback() {
+ when (currentPlayType) {
+ PlayType.RTMP -> {
+ try { player?.setSurface(null) } catch (_: Exception) {}
+ try { player?.stop() } catch (_: Exception) {}
+ try { player?.release() } catch (_: Exception) {}
+ try { player?.destroy() } catch (_: Exception) {}
+
+ val cfg = playerConfig ?: PlayerConfig.forRtmpLive(enableKiwi = true, rsname = "123")
+ player = RtmpPlayer(context = this, playerConfig = cfg)
+ attachRtmpPlayerStateListener()
+
+ lastPlayUrl = null
+ shouldResumePlayback = false
+ currentPlayType = PlayType.NONE
+ updatePlayButtonStates(true)
+ uiState.setRtmpButtonText(false)
+ updateStatus(playStatus = "已停止播放")
+
+ forceRecreatePlaySurface()
+ }
+ PlayType.WHEP -> stopWhepStreaming()
+ PlayType.NONE -> {}
+ }
+ }
+
+ // 强制销毁并重建播放 Surface(通过可见性切换触发 surfaceDestroyed/surfaceCreated)
+ private fun forceRecreatePlaySurface() {
+ try {
+ binding.surfaceViewPlay.visibility = View.GONE
+ binding.surfaceViewPlay.post {
+ ensurePlaySurfaceFormat()
+ binding.surfaceViewPlay.visibility = View.VISIBLE
+ binding.surfaceViewPlay.requestLayout()
+ }
+ } catch (_: Exception) {}
+ }
+
+ /* ---------- WHEP 功能(保留) ---------- */
+ private fun startWhepStreaming(url: String) {
+ stopCurrentPlayback()
+ val whepUrl = url
+ try {
+ // 初始化 WHEP SurfaceViewRenderer
+ if (whepSurfaceView == null) {
+ whepSurfaceView = SurfaceViewRenderer(this)
+ runOnUiThread {
+ whepSurfaceView?.let { surfaceView ->
+ try {
+ if (webrtcEglBase == null) webrtcEglBase = org.webrtc.EglBase.create()
+ surfaceView.init(webrtcEglBase!!.eglBaseContext, null)
+ surfaceView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
+ surfaceView.setEnableHardwareScaler(true)
+ val playContainer = binding.surfaceViewPlay.parent as android.view.ViewGroup
+ val layoutParams = android.view.ViewGroup.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ surfaceView.layoutParams = layoutParams
+ surfaceView.setZOrderOnTop(false)
+ surfaceView.setZOrderMediaOverlay(true)
+ playContainer.addView(surfaceView)
+ binding.surfaceViewPlay.visibility = View.GONE
+ surfaceView.visibility = View.VISIBLE
+ playContainer.requestLayout(); surfaceView.requestLayout()
+ } catch (e: Exception) { Log.e("MainActivity", "Error initializing WHEP view", e); throw e }
+ }
+ }
+ }
+ // 启动播放
+ coroutineScope.launch(Dispatchers.Main) {
+ try {
+ delay(300)
+ whepClient = WhepClient(this@MainActivity, coroutineScope, whepSurfaceView!!, webrtcEglBase!!.eglBaseContext)
+ attachWhepPlayerStateListener()
+ whepClient?.play(whepUrl)
+ runOnUiThread {
+ isWhepPlaying = true
+ currentPlayType = PlayType.WHEP
+ updateWhepButtonText()
+ updatePlayButtonStates(false)
+ Toast.makeText(this@MainActivity, "WHEP拉流已启动", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ runOnUiThread {
+ isWhepPlaying = false; whepClient = null; currentPlayType = PlayType.NONE
+ updateWhepButtonText(); updatePlayButtonStates(true)
+ updateStatus(playStatus = "WHEP播放失败: ${e.message}")
+ Toast.makeText(this@MainActivity, "WHEP拉流启动失败: ${e.message}", Toast.LENGTH_LONG).show()
+ whepSurfaceView?.let { surfaceView ->
+ try { val parent = surfaceView.parent as? android.view.ViewGroup; parent?.removeView(surfaceView) } catch (_: Exception) {}
+ }
+ whepSurfaceView = null
+ binding.surfaceViewPlay.visibility = View.VISIBLE
+ }
+ }
+ }
+ } catch (e: Exception) { Toast.makeText(this, "WHEP拉流初始化失败: ${e.message}", Toast.LENGTH_LONG).show(); updateStatus(playStatus = "WHEP初始化失败") }
+ }
+
+ private fun stopWhepStreaming() {
+ try { whepClient?.stop(); whepClient = null; runOnUiThread {
+ whepSurfaceView?.let { surfaceView ->
+ try { surfaceView.release() } catch (_: Exception) {}
+ val parent = surfaceView.parent as? android.view.ViewGroup; parent?.removeView(surfaceView)
+ }
+ whepSurfaceView = null
+ binding.surfaceViewPlay.visibility = View.VISIBLE
+ ensurePlaySurfaceFormat()
+ } } catch (_: Exception) {}
+ try { webrtcEglBase?.release() } catch (_: Exception) {}
+ webrtcEglBase = null
+ isWhepPlaying = false; currentPlayType = PlayType.NONE
+ updateWhepButtonText(); updatePlayButtonStates(true)
+ updateStatus(playStatus = "WHEP播放已停止")
+ Toast.makeText(this, "WHEP拉流已停止", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // 停止 WHEP
+ if (isWhepPlaying) stopWhepStreaming()
+ try { webrtcEglBase?.release(); webrtcEglBase = null } catch (_: Exception) {}
+ // 释放 StreamingManager
+ streamingManager?.release(); streamingManager = null
+ // 完整销毁播放器(包含协程作用域和 native profile)
+ try { player?.destroy() } catch (_: Exception) { try { player?.release() } catch (_: Exception) {} }
+ player = null
+
+ coroutineScope.cancel()
+ try { binding.surfaceViewPlay.holder.removeCallback(this) } catch (_: Exception) {}
+ try { binding.surfaceViewPush.holder.removeCallback(this) } catch (_: Exception) {}
+ try { coroutineScope.cancel() } catch (_: Exception) {}
+ }
+
+ private fun recreatePlayerAndMaybeResume() {
+ val cfg = playerConfig ?: PlayerConfig.forRtmpLive(enableKiwi = true, rsname = "123")
+ player = RtmpPlayer(context = this, playerConfig = cfg)
+ attachRtmpPlayerStateListener()
+ ensurePlaySurfaceFormat()
+ val holder = binding.surfaceViewPlay.holder
+ if (holder.surface != null && holder.surface.isValid) player?.setSurface(holder.surface)
+ if (shouldResumePlayback && !lastPlayUrl.isNullOrEmpty()) {
+ player?.prepareAsync(lastPlayUrl!!)
+ updatePlayButtonStates(false)
+ uiState.setRtmpButtonText(true)
+ shouldResumePlayback = false
+ }
+ needRecreatePlayer = false
+ }
+
+ private fun attachRtmpPlayerStateListener() {
+ player?.setSCPlayerStateListener { state, detail ->
+ Log.d("MainActivity", "Player State: $state, Detail: $detail")
+ when (state) {
+ SCPlayerState.SCPlayerStateConnecting -> runOnUiThread {
+ val reconnect = detail?.contains("reconnecting") == true
+ updateStatus(playStatus = if (reconnect) "正在重连(RTMP)" else "正在连接(RTMP)")
+ updatePlayButtonStates(false)
+ uiState.setRtmpButtonText(true)
+ }
+ SCPlayerState.SCPlayerStatePlaying -> runOnUiThread { updateStatus(playStatus = "播放中(RTMP)"); updatePlayButtonStates(false); uiState.setRtmpButtonText(true) }
+ SCPlayerState.SCPlayerStatePaused -> runOnUiThread { updateStatus(playStatus = "暂停播放(RTMP)") }
+ SCPlayerState.SCPlayerStateStoppedOrEnded -> runOnUiThread {
+ val text = if (detail == "completed") "播放完成(RTMP)" else "已结束播放(RTMP)"
+ updateStatus(playStatus = text); updatePlayButtonStates(true); uiState.setRtmpButtonText(false)
+ try { player?.setSurface(null) } catch (_: Exception) {}
+ playSurfaceManager.clear()
+ }
+ SCPlayerState.SCPlayerStateFailed -> runOnUiThread {
+ updateStatus(playStatus = "播放错误(RTMP)"); updatePlayButtonStates(true); uiState.setRtmpButtonText(false)
+ try { player?.setSurface(null) } catch (_: Exception) {}
+ playSurfaceManager.clear()
+ }
+ SCPlayerState.SCPlayerStateIdle -> { }
+ }
+ }
+ }
+
+ private fun attachWhepPlayerStateListener() {
+ whepClient?.setSCPlayerStateListener { state, detail ->
+ Log.d("MainActivity", "WHEP Player State: $state, Detail: $detail")
+ when (state) {
+ SCPlayerState.SCPlayerStateConnecting -> runOnUiThread {
+ val statusText = if (detail == "ICE connected") "已连接(WHEP)" else "正在连接(WHEP)"
+ updateStatus(playStatus = statusText); updatePlayButtonStates(false)
+ }
+ SCPlayerState.SCPlayerStatePlaying -> runOnUiThread { updateStatus(playStatus = "播放中(WHEP)"); updatePlayButtonStates(false) }
+ SCPlayerState.SCPlayerStateStoppedOrEnded -> runOnUiThread {
+ isWhepPlaying = false
+ updateStatus(playStatus = "WHEP播放已停止"); updatePlayButtonStates(true)
+ updateWhepButtonText()
+ }
+ SCPlayerState.SCPlayerStateFailed -> runOnUiThread {
+ isWhepPlaying = false
+ updateStatus(playStatus = "WHEP失败: ${detail ?: "未知错误"}"); updatePlayButtonStates(true)
+ updateWhepButtonText()
+ }
+ else -> { }
+ }
+ }
+ }
+
+ /** 计算当前选中分辨率,统一竖屏(宽<高) */
+ private fun currentResolution(): Pair {
+ var (w, h) = when (binding.resolutionGroup.checkedRadioButtonId) {
+ R.id.res360p -> 360 to 640
+ R.id.res540p -> 540 to 960
+ R.id.res720p -> 720 to 1280
+ else -> 720 to 1280
+ }
+ if (w > h) { val t = w; w = h; h = t }
+ return w to h
+ }
+
+ /** 根据不同协议组装播放 URL */
+ private fun buildPlayUrl(protocolType: String, appName: String, streamName: String): String {
+ return when (protocolType) {
+ "RTMP" -> {
+ // RTMP 播放格式: rtmp://rtmp.sellycloud.pull/appName/streamName
+ "rtmp://rtmp.sellycloud.pull/$appName/$streamName"
+ }
+ "WHEP" -> {
+ // WHEP 播放格式 (WHIP推流对应WHEP拉流): https://rtmp.sellycloud.pull/whep/appName/streamName
+ "http://rtmp.sellycloud.pull/$appName/$streamName"
+ }
+ else -> {
+ // 默认使用 RTMP
+ ""
+ }
+ }
+ }
+
+ /** 确保播放 Surface 的像素格式与叠放层设置正确(防止再次播放偏蓝) */
+ private fun ensurePlaySurfaceFormat() {
+ playSurfaceManager.ensureOpaqueFormat()
+
+ }
+
+
+ /** 使用 PixelCopy 截取 Surface 内容并保存到相册(Android 8.0+)。更低版本给出提示。 */
+ private fun captureSurfaceViewAndSave(view: View?, prefix: String) {
+ if (view == null) { Toast.makeText(this, "当前没有可用的视图进行截图", Toast.LENGTH_SHORT).show(); return }
+ if (view.width <= 0 || view.height <= 0) { Toast.makeText(this, "视图尚未布局完成,稍后再试", Toast.LENGTH_SHORT).show(); return }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ Toast.makeText(this, "当前系统版本不支持该截图方式(需Android 8.0+)", Toast.LENGTH_LONG).show(); return
+ }
+ // 仅支持 SurfaceView/其子类
+ if (view !is android.view.SurfaceView) {
+ Toast.makeText(this, "当前视图不支持截图", Toast.LENGTH_SHORT).show(); return
+ }
+ val bmp = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ try {
+ val handler = android.os.Handler(mainLooper)
+ android.view.PixelCopy.request(view, bmp, { result ->
+ if (result == android.view.PixelCopy.SUCCESS) {
+ coroutineScope.launch(Dispatchers.IO) {
+ val ok = saveBitmapToGallery(bmp, prefix)
+ launch(Dispatchers.Main) {
+ Toast.makeText(this@MainActivity, if (ok) "截图已保存到相册" else "保存失败", Toast.LENGTH_SHORT).show()
+ }
+ }
+ } else {
+ Toast.makeText(this, "截图失败,错误码: $result", Toast.LENGTH_SHORT).show()
+ }
+ }, handler)
+ } catch (e: Exception) {
+ Toast.makeText(this, "截图异常: ${e.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+
+ /** 保存位图到系统相册(按API等级分别处理) */
+ private fun saveBitmapToGallery(bitmap: Bitmap, prefix: String): Boolean {
+ val filename = "${prefix}_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.getDefault()).format(java.util.Date())}.png"
+ return try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val values = android.content.ContentValues().apply {
+ put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, filename)
+ put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
+ put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/")
+ put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
+ }
+ val resolver = contentResolver
+ val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
+ if (uri != null) {
+ resolver.openOutputStream(uri)?.use { out ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
+ }
+ values.clear()
+ values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
+ resolver.update(uri, values, null, null)
+ true
+ } else false
+ } else {
+ // API 29 以下,保存到公共图片目录(需要WRITE_EXTERNAL_STORAGE权限,已在Manifest按maxSdk申明)
+ val picturesDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_PICTURES)
+ val targetDir = java.io.File(picturesDir, "RTMPDemo").apply { if (!exists()) mkdirs() }
+ val file = java.io.File(targetDir, filename)
+ java.io.FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) }
+ // 通知相册扫描
+ val values = android.content.ContentValues().apply {
+ put(android.provider.MediaStore.Images.Media.DATA, file.absolutePath)
+ put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
+ put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, filename)
+ }
+ contentResolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
+ true
+ }
+ } catch (e: Exception) {
+ Log.e("MainActivity", "saveBitmapToGallery error", e)
+ false
+ }
+ }
+
+ private fun bundleToMap(bundle: Bundle?): Map {
+ if (bundle == null) return emptyMap()
+ val map = mutableMapOf()
+ for (key in bundle.keySet()) {
+ val value = bundle.get(key)
+ map[key] = when (value) {
+ is Bundle -> bundleToMap(value)
+ is IntArray -> value.toList()
+ is LongArray -> value.toList()
+ is FloatArray -> value.toList()
+ is DoubleArray -> value.toList()
+ is BooleanArray -> value.toList()
+ is ByteArray -> value.joinToString(prefix = "[", postfix = "]")
+ is Array<*> -> value.toList()
+ else -> value
+ }
+ }
+ return map
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ onBackPressedDispatcher.onBackPressed()
+ return true
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/MultiPlayActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/MultiPlayActivity.kt
new file mode 100644
index 0000000..1fd6205
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/live/MultiPlayActivity.kt
@@ -0,0 +1,155 @@
+package com.demo.SellyCloudSDK.live
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import android.widget.Button
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import com.demo.SellyCloudSDK.R
+import com.sellycloud.sellycloudsdk.MultiRtmpPlayer
+import com.sellycloud.sellycloudsdk.PlayerConfig
+
+class MultiPlayActivity : AppCompatActivity(), MultiRtmpPlayer.MultiRtmpPlayerListener {
+
+ private lateinit var etNewUrl: EditText
+ private lateinit var btnAddStream: Button
+ private lateinit var btnStartAll: Button
+ private lateinit var btnStopAll: Button
+ private lateinit var streamsContainer: LinearLayout
+
+ private lateinit var multiPlayer: MultiRtmpPlayer
+ private var streamCounter = 1
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_multi_play)
+
+ etNewUrl = findViewById(R.id.etNewUrl)
+ btnAddStream = findViewById(R.id.btnAddStream)
+ btnStartAll = findViewById(R.id.btnStartAll)
+ btnStopAll = findViewById(R.id.btnStopAll)
+ streamsContainer = findViewById(R.id.streamsContainer)
+
+ multiPlayer = MultiRtmpPlayer(this, this, PlayerConfig.forRtmpLive())
+
+ btnAddStream.setOnClickListener {
+ val urlInput = etNewUrl.text.toString().trim()
+ val id = "stream_${streamCounter++}"
+
+ val config = if (urlInput.isEmpty()) {
+ // 未输入 URL 时,示例启用 Kiwi 使用默认 rs 标识,可按需替换
+ PlayerConfig.forRtmpLive(enableKiwi = true, rsname = "123")
+ } else {
+ PlayerConfig.forRtmpLive()
+ }
+
+ val ok = multiPlayer.addStream(id, urlInput.ifEmpty { "rtmp://placeholder/kiwi" }, config)
+ if (!ok) {
+ Toast.makeText(this, "添加失败:ID重复", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ addStreamItemView(id)
+ }
+
+ btnStartAll.setOnClickListener {
+ multiPlayer.currentStreams().forEach { id ->
+ if (multiPlayer.isPrepared(id)) multiPlayer.start(id) else multiPlayer.prepareAsync(id)
+ }
+ }
+ btnStopAll.setOnClickListener {
+ multiPlayer.currentStreams().forEach { id -> multiPlayer.stop(id) }
+ }
+ }
+
+ private fun addStreamItemView(streamId: String) {
+ val item = LayoutInflater.from(this).inflate(R.layout.item_stream_player, streamsContainer, false)
+ val tvTitle = item.findViewById(R.id.tvTitle)
+ val tvStatus = item.findViewById(R.id.tvStatus)
+ val surfaceView = item.findViewById(R.id.surfaceView)
+ val btnPrepare = item.findViewById