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