Initial commit

This commit is contained in:
Shou 2025-12-03 04:29:51 +08:00
parent ad17d7d785
commit e09271a60e
43 changed files with 6128 additions and 1 deletions

153
KIWI_DECOUPLING_GUIDE.md Normal file
View File

@ -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
View File

@ -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而是在你们自己的业务服务器上生成 TokenApp 只向服务器请求 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)
```

14
build.gradle Normal file
View File

@ -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
}

86
example/build.gradle Normal file
View File

@ -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'
}

BIN
example/libs/Kiwi.aar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
example/libs/libwebrtc.aar Normal file

Binary file not shown.

Binary file not shown.

7
example/proguard-rules.pro vendored Normal file
View File

@ -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.**

View File

@ -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>

View File

@ -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))
}
}
}

View File

@ -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
// }
//}

View File

@ -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
}

View File

@ -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) {}
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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
)
}

View File

@ -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) }
}
}

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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) {}
}
}

View File

@ -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) {}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,7 @@
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
void main() {
gl_FragColor = texture2D(uSampler, vTextureCoord);
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

20
gradle.properties Normal file
View File

@ -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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -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

251
gradlew vendored Executable file
View File

@ -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" "$@"

94
gradlew.bat vendored Normal file
View File

@ -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

22
settings.gradle Normal file
View File

@ -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'