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