Initial commit
This commit is contained in:
parent
ad17d7d785
commit
e09271a60e
|
|
@ -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转换
|
||||
643
README.md
643
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
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
```
|
||||
|
||||
在 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
|
||||
<string name="signaling_app_id">your-app-id</string>
|
||||
<string name="signaling_secret">your-secret</string>
|
||||
<string name="signaling_token"></string> <!-- 可选:直接写死 token -->
|
||||
```
|
||||
|
||||
> 生产环境建议:
|
||||
> 不要在 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<String, SurfaceViewRenderer>()
|
||||
|
||||
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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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.**
|
||||
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="SellyCloudRTC Demo"
|
||||
android:theme="@style/Theme.AppCompat.Light.DarkActionBar"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".FeatureHubActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:screenOrientation="fullSensor">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".live.MainActivity"
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:screenOrientation="fullSensor"
|
||||
android:parentActivityName=".FeatureHubActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".interactive.InteractiveLiveActivity"
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:screenOrientation="portrait"
|
||||
android:parentActivityName=".FeatureHubActivity" />
|
||||
|
||||
<!-- 新增:多路播放页面 -->
|
||||
<activity
|
||||
android:name=".live.MultiPlayActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".interactive.InteractiveForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="camera|microphone" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
// }
|
||||
//}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VideoSlot>
|
||||
private val remoteRendererMap = mutableMapOf<String, SurfaceViewRenderer>()
|
||||
private var isLocalPreviewEnabled = true
|
||||
private var isLocalAudioEnabled = true
|
||||
private var isSpeakerOn = true
|
||||
private var localStats: InteractiveStreamStats? = null
|
||||
private val remoteStats = mutableMapOf<String, InteractiveStreamStats>()
|
||||
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<String, MediaState>()
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Int>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Int, Int> {
|
||||
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<String, Any?> {
|
||||
if (bundle == null) return emptyMap()
|
||||
val map = mutableMapOf<String, Any?>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TextView>(R.id.tvTitle)
|
||||
val tvStatus = item.findViewById<TextView>(R.id.tvStatus)
|
||||
val surfaceView = item.findViewById<SurfaceView>(R.id.surfaceView)
|
||||
val btnPrepare = item.findViewById<Button>(R.id.btnPrepare)
|
||||
val btnStart = item.findViewById<Button>(R.id.btnStart)
|
||||
val btnStop = item.findViewById<Button>(R.id.btnStop)
|
||||
val btnRemove = item.findViewById<Button>(R.id.btnRemove)
|
||||
|
||||
tvTitle.text = "流: $streamId"
|
||||
tvStatus.text = "状态: 已添加,待准备"
|
||||
|
||||
surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
multiPlayer.setSurface(streamId, holder.surface)
|
||||
}
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
multiPlayer.setSurface(streamId, null)
|
||||
}
|
||||
})
|
||||
|
||||
btnPrepare.setOnClickListener {
|
||||
tvStatus.text = "状态: 准备中"
|
||||
multiPlayer.prepareAsync(streamId)
|
||||
}
|
||||
btnStart.setOnClickListener {
|
||||
if (multiPlayer.isPrepared(streamId)) {
|
||||
multiPlayer.start(streamId)
|
||||
} else {
|
||||
Toast.makeText(this, "请先准备该流", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
btnStop.setOnClickListener { multiPlayer.stop(streamId) }
|
||||
btnRemove.setOnClickListener {
|
||||
multiPlayer.release(streamId)
|
||||
streamsContainer.removeView(item)
|
||||
}
|
||||
|
||||
streamsContainer.addView(item)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
multiPlayer.releaseAll()
|
||||
}
|
||||
|
||||
// MultiRtmpPlayerListener 实现
|
||||
override fun onPlayerPrepared(streamId: String) {
|
||||
updateStatus(streamId, "准备完成")
|
||||
}
|
||||
|
||||
override fun onPlayerStarted(streamId: String) {
|
||||
updateStatus(streamId, "播放中")
|
||||
}
|
||||
|
||||
override fun onPlayerError(streamId: String, error: String) {
|
||||
updateStatus(streamId, "错误: $error")
|
||||
}
|
||||
|
||||
override fun onPlayerCompleted(streamId: String) {
|
||||
updateStatus(streamId, "播放完成")
|
||||
}
|
||||
|
||||
override fun onPlayerBuffering(streamId: String, percent: Int) {
|
||||
updateStatus(streamId, "缓冲中...$percent%")
|
||||
}
|
||||
|
||||
override fun onPlayerInfo(streamId: String, what: Int, extra: Int) {
|
||||
// 可按需处理更多 info
|
||||
}
|
||||
|
||||
private fun updateStatus(streamId: String, status: String) {
|
||||
runOnUiThread {
|
||||
for (i in 0 until streamsContainer.childCount) {
|
||||
val item = streamsContainer.getChildAt(i)
|
||||
val title = item.findViewById<TextView>(R.id.tvTitle)
|
||||
if (title.text.endsWith(streamId)) {
|
||||
val tvStatus = item.findViewById<TextView>(R.id.tvStatus)
|
||||
tvStatus.text = "状态: $status"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.demo.SellyCloudSDK.live
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
|
||||
/**
|
||||
* Minimal manager for the playback SurfaceView to keep MainActivity lean.
|
||||
* Encapsulates pixel format setup and clearing the surface to black.
|
||||
*/
|
||||
class PlaySurfaceManager(private val surfaceView: SurfaceView) {
|
||||
|
||||
// 用于视频播放,使用 OPAQUE 格式避免颜色问题
|
||||
fun ensureOpaqueFormat() {
|
||||
surfaceView.setZOrderMediaOverlay(false)
|
||||
surfaceView.setZOrderOnTop(false)
|
||||
surfaceView.holder.setFormat(PixelFormat.OPAQUE)
|
||||
}
|
||||
fun clear() {
|
||||
val holder: SurfaceHolder = surfaceView.holder
|
||||
try {
|
||||
val canvas = holder.lockCanvas()
|
||||
if (canvas != null) {
|
||||
canvas.drawColor(Color.BLACK)
|
||||
holder.unlockCanvasAndPost(canvas)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.demo.SellyCloudSDK.live
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.SurfaceHolder
|
||||
import com.demo.SellyCloudSDK.databinding.ActivityMainBinding
|
||||
|
||||
/**
|
||||
* Thin UI state helper to centralize status text and button states.
|
||||
* MainActivity delegates to this manager to reduce duplication.
|
||||
* No business logic is changed.
|
||||
*/
|
||||
class UiStateManager(private val binding: ActivityMainBinding) {
|
||||
|
||||
fun setPushStatusText(text: String, currentPlayStatus: String) {
|
||||
binding.tvStatus.text = "推流状态: $text | 播放状态: $currentPlayStatus"
|
||||
}
|
||||
|
||||
fun setPlayStatusText(currentPushStatus: String, playStatus: String) {
|
||||
binding.tvStatus.text = "推流状态: $currentPushStatus | 播放状态: $playStatus"
|
||||
}
|
||||
|
||||
fun setPushButtonsEnabled(isPushing: Boolean) {
|
||||
binding.btnStartPush.isEnabled = !isPushing
|
||||
binding.btnStopPush.isEnabled = isPushing
|
||||
}
|
||||
|
||||
// Keep the RTMP play button enabled so it can serve as a Start/Stop toggle
|
||||
fun setPlayButtonEnabled(@Suppress("UNUSED_PARAMETER") enabled: Boolean) {
|
||||
binding.btnPlay.isEnabled = true
|
||||
}
|
||||
|
||||
fun setRtmpButtonText(isPlaying: Boolean) {
|
||||
binding.btnPlay.text = if (isPlaying) "停止播放(RTMP)" else "开始播放(RTMP)"
|
||||
}
|
||||
|
||||
fun setWhepButtonText(isWhepPlaying: Boolean) {
|
||||
binding.btnWhepPlay.text = if (isWhepPlaying) "停止播放(WHEP)" else "开始播放(WHEP)"
|
||||
}
|
||||
|
||||
fun setPushPreviewHeader(mode: String) {
|
||||
binding.tvPushPreviewHeader.text = "📹 推流预览($mode)"
|
||||
}
|
||||
|
||||
fun clearSurface(holder: SurfaceHolder) {
|
||||
try {
|
||||
val canvas = holder.lockCanvas()
|
||||
if (canvas != null) {
|
||||
canvas.drawColor(Color.BLACK)
|
||||
holder.unlockCanvasAndPost(canvas)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FFFFFF"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHubSubtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/hub_subtitle_basic"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="#101215"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scrollCards"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/tvHubSubtitle"
|
||||
app:layout_constraintVertical_bias="0.0">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/cardLiveStreaming"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/brand_primary"
|
||||
app:cardCornerRadius="10dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardUseCompatPadding="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/live_streaming_title"
|
||||
android:textColor="@color/brand_primary_text_on"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/live_streaming_subtitle"
|
||||
android:textColor="@color/brand_primary_text_sub"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@android:drawable/ic_media_next"
|
||||
android:contentDescription="@null"
|
||||
app:tint="#E6FFFFFF" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/cardInteractiveLive"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/brand_primary"
|
||||
app:cardCornerRadius="10dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardUseCompatPadding="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/interactive_live_title"
|
||||
android:textColor="@color/brand_primary_text_on"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/interactive_live_subtitle"
|
||||
android:textColor="@color/brand_primary_text_sub"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@android:drawable/ic_media_next"
|
||||
android:contentDescription="@null"
|
||||
app:tint="#E6FFFFFF" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/video_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/video_container_row1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.demo.SellyCloudSDK.interactive.VideoReportLayout
|
||||
android:id="@+id/fl_local"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<com.demo.SellyCloudSDK.interactive.VideoReportLayout
|
||||
android:id="@+id/fl_remote1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/video_container_row2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.demo.SellyCloudSDK.interactive.VideoReportLayout
|
||||
android:id="@+id/fl_remote2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<com.demo.SellyCloudSDK.interactive.VideoReportLayout
|
||||
android:id="@+id/fl_remote3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/controls_panel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:elevation="8dp"
|
||||
android:padding="12dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_join"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_call_id"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/call_id"
|
||||
android:singleLine="true"
|
||||
android:textColor="@android:color/black"
|
||||
android:textColorHint="#80FFFFFF" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_join"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/join" />
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_user_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/user_id"
|
||||
android:singleLine="true"
|
||||
android:textColor="@android:color/black"
|
||||
android:textColorHint="#80FFFFFF" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/call_type_group"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/rb_call_type_p2p"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true"
|
||||
android:text="@string/call_type_one_to_one" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/rb_call_type_group"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:text="@string/call_type_group" />
|
||||
</RadioGroup>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_call_info"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/call_status_idle"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/bottom_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/action_controls_primary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:weightSum="3">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_toggle_local_preview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/ctrl_local_preview_off" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_toggle_local_publish"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/ctrl_local_publish_off" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_switch_camera"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/switch_camera" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/action_controls_secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:weightSum="4">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_toggle_audio_route"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/ctrl_audio_speaker" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_toggle_camera"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/ctrl_camera_off" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_toggle_beauty"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/ctrl_beauty_off" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_toggle_mic"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/ctrl_mic_off" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/message_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_message"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/message_hint"
|
||||
android:singleLine="true"
|
||||
android:textColor="@android:color/black"
|
||||
android:textColorHint="#80FFFFFF" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_send_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/send_message" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_message_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -0,0 +1,576 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000">
|
||||
|
||||
<!-- 顶部:推流行(输入框 + 按钮列) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/pushRow"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<!-- 推流配置输入区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:background="#333333"
|
||||
android:padding="4dp">
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="#555555"
|
||||
android:layout_marginVertical="1dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etAppName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/transparent"
|
||||
android:hint="App Name (如: live)"
|
||||
android:text="live"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textColorHint="#CCCCCC"
|
||||
android:textSize="11sp"
|
||||
android:inputType="text"
|
||||
android:importantForAutofill="no"
|
||||
android:padding="2dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="#555555"
|
||||
android:layout_marginVertical="1dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etStreamName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/transparent"
|
||||
android:hint="Stream Name (如: stream123)"
|
||||
android:text="stream001"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textColorHint="#CCCCCC"
|
||||
android:textSize="11sp"
|
||||
android:inputType="text"
|
||||
android:importantForAutofill="no"
|
||||
android:padding="2dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="#555555"
|
||||
android:layout_marginVertical="1dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/pushButtonContainer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="2dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStartPush"
|
||||
android:layout_width="wrap_content"
|
||||
android:minWidth="120dp"
|
||||
android:layout_height="32dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:backgroundTint="#4CAF50"
|
||||
android:text="开始推流"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStopPush"
|
||||
android:layout_width="wrap_content"
|
||||
android:minWidth="120dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:backgroundTint="#F44336"
|
||||
android:text="停止推流"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 新增:协议选择(RTMP / WHIP) -->
|
||||
<RadioGroup
|
||||
android:id="@+id/protocolGroup"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/pushRow">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/rbProtocolRtmp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="RTMP"
|
||||
android:checked="true"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/rbProtocolWhip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:text="WHIP"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
</RadioGroup>
|
||||
|
||||
<!-- 推流控制:放在协议选择下方 -->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/pushControlsContainer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/protocolGroup">
|
||||
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/resolutionScrollView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:scrollbars="none"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnSwitchCamera"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/resolutionGroup"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/res360p"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:text="360p"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/res540p"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:text="540p"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/res720p"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:checked="true"
|
||||
android:text="720p"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
<RadioButton
|
||||
android:id="@+id/res1080p"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:text="1080p"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
</RadioGroup>
|
||||
</HorizontalScrollView>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSwitchCamera"
|
||||
android:layout_width="wrap_content"
|
||||
android:minWidth="110dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:backgroundTint="#FF9800"
|
||||
android:text="切换摄像头"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnSwitchOrientation"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintWidth_default="wrap" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSwitchOrientation"
|
||||
android:layout_width="wrap_content"
|
||||
android:minWidth="110dp"
|
||||
android:layout_height="32dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:backgroundTint="#03A9F4"
|
||||
android:text="切换方向"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintWidth_default="wrap" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<!-- 镜像控制:精简为两个统一镜像(同时作用预览与推流) + 美颜 -->
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/mirrorScrollView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:scrollbars="none"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/pushControlsContainer">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/cbPreviewHFlip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:text="水平镜像"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/cbPreviewVFlip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text="垂直镜像"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switchBeauty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text="美颜"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:checked="true"
|
||||
app:thumbTint="#4CAF50"
|
||||
app:trackTint="#81C784" />
|
||||
</LinearLayout>
|
||||
</HorizontalScrollView>
|
||||
|
||||
<!-- 播放行(输入框 + 按钮列):放在镜像控制下方 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/playRow"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/mirrorScrollView">
|
||||
|
||||
<!-- 播放配置输入区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:background="#333333"
|
||||
android:padding="4dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etPlayAppName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/transparent"
|
||||
android:hint="Play App Name (如: live)"
|
||||
android:text="live"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textColorHint="#CCCCCC"
|
||||
android:textSize="11sp"
|
||||
android:inputType="text"
|
||||
android:importantForAutofill="no"
|
||||
android:padding="2dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="#555555"
|
||||
android:layout_marginVertical="1dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etPlayStreamName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/transparent"
|
||||
android:hint="Play Stream Name (如: stream123)"
|
||||
android:text="stream001"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textColorHint="#CCCCCC"
|
||||
android:textSize="11sp"
|
||||
android:inputType="text"
|
||||
android:importantForAutofill="no"
|
||||
android:padding="2dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/playButtonContainer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="2dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPlay"
|
||||
android:layout_width="wrap_content"
|
||||
android:minWidth="140dp"
|
||||
android:layout_height="32dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:backgroundTint="#2196F3"
|
||||
android:text="开始播放(RTMP)"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnWhepPlay"
|
||||
android:layout_width="wrap_content"
|
||||
android:minWidth="140dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:backgroundTint="#E91E63"
|
||||
android:text="开始播放(WHEP)"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 功能按钮区域 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/functionButtonsLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:orientation="horizontal"
|
||||
android:weightSum="2"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/playRow">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnChooseImageSource"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:text="图片作为视频源"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:backgroundTint="#E91E63" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRestoreCamera"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="1dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:backgroundTint="#607D8B"
|
||||
android:text="恢复摄像头源"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 新增:截图按钮行 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/screenshotButtonsLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:orientation="horizontal"
|
||||
android:weightSum="2"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/functionButtonsLayout">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnCapturePush"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:text="截图(推流预览)"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:backgroundTint="#607D8B" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnCapturePlay"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="1dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:maxLines="1"
|
||||
android:text="截图(播放)"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:backgroundTint="#607D8B" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 状态信息 -->
|
||||
|
||||
<!-- 视频显示区域:剩余空间自适应;设置最小高度 -->
|
||||
<TextView
|
||||
android:id="@+id/tvStatus"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:background="#80000000"
|
||||
android:padding="4dp"
|
||||
android:text="推流状态: 待启动 | 播放状态: 待启动"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/screenshotButtonsLayout" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/videoContainer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:orientation="horizontal"
|
||||
android:weightSum="2"
|
||||
android:baselineAligned="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/tvStatus">
|
||||
|
||||
<!-- 推流预览 -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:layout_weight="1"
|
||||
android:background="#222222"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPushPreviewHeader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#555555"
|
||||
android:gravity="center"
|
||||
android:padding="3dp"
|
||||
android:text="📹 推流预览"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.pedro.library.view.OpenGlView
|
||||
android:id="@+id/surfaceViewPush"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:minHeight="160dp" />
|
||||
|
||||
<!-- 新增:WHIP 预览渲染器,按需显示 -->
|
||||
<org.webrtc.SurfaceViewRenderer
|
||||
android:id="@+id/whipPreview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 拉流播放 -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_weight="1"
|
||||
android:background="#222222"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#555555"
|
||||
android:gravity="center"
|
||||
android:padding="3dp"
|
||||
android:text="📺 拉流播放"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<SurfaceView
|
||||
android:id="@+id/surfaceViewPlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:minHeight="160dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etNewUrl"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:hint="输入RTMP地址或留空使用Kiwi"
|
||||
android:padding="12dp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textColorHint="#CCCCCC"
|
||||
android:background="#333333"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnAddStream"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnAddStream"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="添加"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="#4CAF50"
|
||||
app:layout_constraintTop_toTopOf="@id/etNewUrl"
|
||||
app:layout_constraintBottom_toBottomOf="@id/etNewUrl"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnStartAll"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStartAll"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="全部开始"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="#2196F3"
|
||||
app:layout_constraintTop_toTopOf="@id/etNewUrl"
|
||||
app:layout_constraintBottom_toBottomOf="@id/etNewUrl"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnStopAll"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStopAll"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="全部停止"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="#F44336"
|
||||
app:layout_constraintTop_toTopOf="@id/etNewUrl"
|
||||
app:layout_constraintBottom_toBottomOf="@id/etNewUrl"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1.0"/>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scrollStreams"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_margin="8dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/etNewUrl"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/streamsContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"/>
|
||||
</ScrollView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp"
|
||||
android:background="@android:color/white">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="美颜设置"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- 美颜开关 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="启用美颜"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@android:color/black" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/switchBeautyEnable"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 磨皮强度 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="磨皮强度"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekBarBeautyIntensity"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="100"
|
||||
android:progress="60" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBeautyValue"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="6.0"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 滤镜强度 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="滤镜强度"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekBarFilterIntensity"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="10"
|
||||
android:progress="7" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFilterValue"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0.7"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 美白强度 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="美白强度"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekBarColorIntensity"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="10"
|
||||
android:progress="5" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvColorValue"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0.5"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 红润强度 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="红润强度"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekBarRedIntensity"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="10"
|
||||
android:progress="5" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRedValue"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0.5"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 亮眼强度 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="亮眼强度"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekBarEyeBrightIntensity"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="10"
|
||||
android:progress="10" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvEyeBrightValue"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1.0"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 美牙强度 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="美牙强度"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekBarToothIntensity"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="10"
|
||||
android:progress="10" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToothValue"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1.0"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<Button
|
||||
android:id="@+id/btnClose"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="关闭"
|
||||
android:backgroundTint="#607D8B"
|
||||
android:textColor="@android:color/white" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="#222222"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:background="#444444"
|
||||
android:text="流"/>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:background="#000000">
|
||||
|
||||
<SurfaceView
|
||||
android:id="@+id/surfaceView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"/>
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end"
|
||||
android:padding="8dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPrepare"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="准备"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="#03A9F4"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStart"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="开始"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="#2196F3"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStop"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="停止"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="#F44336"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRemove"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="移除"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="#9E9E9E"/>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="12sp"
|
||||
android:padding="8dp"
|
||||
android:text="状态: 待添加"/>
|
||||
</LinearLayout>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
precision mediump float;
|
||||
varying vec2 vTextureCoord;
|
||||
uniform sampler2D uSampler;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uSampler, vTextureCoord);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
attribute vec4 aPosition;
|
||||
attribute vec4 aTextureCoord;
|
||||
|
||||
uniform mat4 uMVPMatrix;
|
||||
uniform mat4 uSTMatrix;
|
||||
|
||||
varying vec2 vTextureCoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = uMVPMatrix * aPosition;
|
||||
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Brand color from the back button backgroundTint -->
|
||||
<color name="brand_primary">#2A82FF</color>
|
||||
<color name="brand_primary_text_on">#FFFFFF</color>
|
||||
<color name="brand_primary_text_sub">#E6FFFFFF</color>
|
||||
</resources>
|
||||
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">RTMPDEMO</string>
|
||||
|
||||
<!-- Feature Hub -->
|
||||
<string name="hub_subtitle_basic">选择你要体验的场景</string>
|
||||
<string name="live_streaming_title">直播推拉流</string>
|
||||
<string name="live_streaming_subtitle">RTMP / WHIP 推流、拉流示例</string>
|
||||
<string name="interactive_live_title">VideoCall</string>
|
||||
<string name="interactive_live_subtitle">多人语音、视频互动体验</string>
|
||||
|
||||
<!-- Interactive Live -->
|
||||
<string name="switch_camera">切换摄像头</string>
|
||||
<string name="call_id">Call ID</string>
|
||||
<string name="user_id">User ID</string>
|
||||
<string name="join">加入</string>
|
||||
<string name="leave">离开</string>
|
||||
<string name="default_call_id">demo-call</string>
|
||||
<string name="default_user_id">user-%1$s</string>
|
||||
<string name="signaling_endpoint">ws://219.74.166.89:8089/ws/signaling</string>
|
||||
<string name="signaling_app_id">demo-app</string>
|
||||
<string name="signaling_secret">CHANGE_ME</string>
|
||||
<string name="signaling_token"></string>
|
||||
<string name="permission_required">需要相机和麦克风权限才能体验互动直播</string>
|
||||
<string name="signaling_app_id_missing">请在 strings.xml 中配置有效的 Signaling App ID</string>
|
||||
<string name="call_id_required">Call ID 不能为空</string>
|
||||
<string name="user_id_required">User ID 不能为空</string>
|
||||
<string name="call_type_one_to_one">1 对 1</string>
|
||||
<string name="call_type_group">多方通话</string>
|
||||
<string name="ctrl_local_preview_off">关闭预览</string>
|
||||
<string name="ctrl_local_preview_on">开启预览</string>
|
||||
<string name="ctrl_local_publish_off">停止推送</string>
|
||||
<string name="ctrl_local_publish_on">恢复推送</string>
|
||||
<string name="ctrl_remote_off">静音远端</string>
|
||||
<string name="ctrl_remote_on">开启远端</string>
|
||||
<string name="ctrl_audio_speaker">扬声器</string>
|
||||
<string name="ctrl_audio_earpiece">听筒</string>
|
||||
<string name="ctrl_mic_off">关闭麦克风</string>
|
||||
<string name="ctrl_mic_on">开启麦克风</string>
|
||||
<string name="ctrl_camera_off">关闭摄像头</string>
|
||||
<string name="ctrl_camera_on">开启摄像头</string>
|
||||
<string name="message_hint">发送频道广播消息</string>
|
||||
<string name="send_message">发送</string>
|
||||
<string name="ctrl_beauty_on">美颜开启</string>
|
||||
<string name="ctrl_beauty_off">美颜关闭</string>
|
||||
<string name="call_status_idle">状态: 未连接</string>
|
||||
<string name="call_status_connected">状态: 已连接</string>
|
||||
<string name="call_status_connecting">状态: 连接中…</string>
|
||||
<string name="call_status_reconnecting">状态: 重连中…</string>
|
||||
<string name="call_status_failed">状态: 失败</string>
|
||||
<string name="message_none">暂无消息</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">10.0.0.0/8</domain>
|
||||
<domain includeSubdomains="true">172.16.0.0/12</domain>
|
||||
<domain includeSubdomains="true">192.168.0.0/16</domain>
|
||||
</domain-config>
|
||||
<!-- 如果需要连接外部服务器,可以添加具体域名 -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
android.useAndroidX=true
|
||||
android.enableJetifier=false
|
||||
|
||||
# Increase Gradle daemon heap to avoid OOM during packaging large AAR/assets
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dkotlin.daemon.jvm.options="-Xmx2g"
|
||||
|
||||
# SellyCloud SDK publishing metadata
|
||||
sellySdkGroupId=com.sellycloud
|
||||
sellySdkArtifactId=sellycloudsdk
|
||||
sellySdkVersion=1.0.0
|
||||
# Optional: local folder repository for sharing the built AAR (relative to project root)
|
||||
sellySdkPublishRepo=build/maven-repo
|
||||
|
||||
# --- Signing (self-signed keystore) ---
|
||||
# Path is relative to the root project dir
|
||||
MY_STORE_FILE=release.keystore
|
||||
# Keep these secrets locally; do not commit real creds to VCS
|
||||
MY_STORE_PASSWORD=rtmpdemo123
|
||||
MY_KEY_ALIAS=rtmpdemo
|
||||
MY_KEY_PASSWORD=rtmpdemo123
|
||||
Binary file not shown.
|
|
@ -0,0 +1,8 @@
|
|||
#Mon Jul 14 00:41:40 SGT 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||
repositories {
|
||||
mavenLocal()
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://jitpack.io' }
|
||||
// Local AARs for SellyCloudSDK
|
||||
flatDir { dirs file('SellyCloudSDK/libs') }
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "SellyCLoudSDKExample"
|
||||
include ':example'
|
||||
include ':SellyCloudSDK'
|
||||
Loading…
Reference in New Issue