demo:新增xor播放支持

sdk:player: texture 播放链优化
This commit is contained in:
2026-04-13 10:09:24 +08:00
parent 103145d5e6
commit 54a78130b1
11 changed files with 876 additions and 59 deletions

View File

@@ -582,7 +582,9 @@ player.play()
- `RTMP` 播放支持 `SurfaceView``TextureView``SurfaceTexture` - `RTMP` 播放支持 `SurfaceView``TextureView``SurfaceTexture`
- `RTC/WHEP` 播放支持 `SurfaceViewRenderer``TextureView`,以及高级场景下的 `SurfaceTexture` - `RTC/WHEP` 播放支持 `SurfaceViewRenderer``TextureView`,以及高级场景下的 `SurfaceTexture`
- 当前版本建议在 **开始播放前** 选定渲染后端;当前 Demo 在首页设置中统一选择,进入页面后不再暴露热切换 - `RTMP/VOD``TextureView / SurfaceTexture` 默认走 **direct output**,优先保证首帧和低延迟
- 当前版本建议在 **开始播放前** 选定渲染后端;运行中如需变更目标,请走 `clearRenderTarget()` + `setRenderView(...)` / `setRenderSurfaceTexture(...)` 的显式重绑流程
- Flutter 场景优先使用 `setRenderSurfaceTexture(...)`,配合 Flutter `Texture` widget 使用;如 UI 层级正确性优先,不建议继续依赖 `Hybrid Composition + SurfaceView`
控制接口: 控制接口:
@@ -599,6 +601,29 @@ player.play()
- `clearRenderTarget()`:解绑当前渲染面,播放会话可继续存活 - `clearRenderTarget()`:解绑当前渲染面,播放会话可继续存活
- `seekBy(deltaMs)`:播放进度跳转(仅在流支持快进/回放时有效) - `seekBy(deltaMs)`:播放进度跳转(仅在流支持快进/回放时有效)
### 7.3.2 Flutter / SurfaceTexture 接入建议
如果业务侧需要把视频放到 Flutter UI 层下面,并正常叠加按钮、封面、弹层、动画,推荐使用:
- Flutter 侧创建 `TextureRegistry.SurfaceTextureEntry`
- Android 插件层取出 `SurfaceTexture`
- 调用 `setRenderSurfaceTexture(surfaceTexture, width, height)`
- Flutter 页面使用 `Texture(textureId)` 显示视频
示意:
```kotlin
player.setRenderSurfaceTexture(surfaceTexture, width, height)
player.prepareToPlay()
player.play()
```
说明:
- `SurfaceTexture` 生命周期由调用方负责
- 销毁前建议先调用 `clearRenderTarget()` 或直接 `release()`
- 如果页面重建、Texture 重新申请或 Flutter 侧切换 textureId需要重新绑定新的 `SurfaceTexture`
### 7.4 播放回调 ### 7.4 播放回调
```kotlin ```kotlin
@@ -622,6 +647,60 @@ player.delegate = object : SellyLiveVideoPlayerDelegate {
- `Reconnecting` - `Reconnecting`
- `Failed` - `Failed`
首帧语义说明:
- 默认 `DIRECT` 模式下,`onFirstVideoFrameRendered()` 对应 decoder 首帧可用时机
-`TextureView / SurfaceTexture` 且启用了 playback processing 的场景SDK 会等待目标渲染面确认首帧已真正呈现后,再触发 `onFirstVideoFrameRendered()`
- `onFirstAudioFrameRendered()` 仍表示音频首帧可播放时机;在 texture-backed processing 场景中,音频与视频首帧不一定完全同一时刻
### 7.4.1 播放侧帧回调与二次处理
播放器支持一组独立于采集/推流链路的播放侧高级能力:
- `setPlaybackFrameObserver(observer)`:播放侧只读帧回调
- `setPlaybackVideoProcessor(processor)`:播放侧可写纹理处理
当前能力边界:
- 当前仅支持 **texture-backed** 播放目标:`TextureView` / `SurfaceTexture`
- 当前仅支持 `preferredFormat = TEXTURE_2D`
- 当前仅支持 `stage = RENDER_PRE_DISPLAY`
- 当前默认渲染模式为 `DIRECT`
- 只有设置了有效的 observer / processor才会切到 `PROCESSING`
- 如果当前 render target 已经绑定,新增或移除 observer / processor 后,需要 **重绑一次 texture render target** 才会生效
- `RTC/WHEP` 播放当前不支持这套 playback processing当前主要用于 `RTMP/VOD` 播放链
只读 observer 示例:
```kotlin
player.setPlaybackFrameObserver(object : PlaybackFrameObserver {
override val config = PlaybackFrameObserverConfig(
preferredFormat = VideoProcessFormat.TEXTURE_2D,
stage = VideoStage.RENDER_PRE_DISPLAY
)
override fun onTextureFrame(frame: VideoTextureFrame) {
// 读取播放侧纹理帧信息
}
})
```
可写 processor 示例:
```kotlin
player.setPlaybackVideoProcessor(object : PlaybackVideoProcessor {
override val config = PlaybackVideoProcessorConfig(
preferredFormat = VideoProcessFormat.TEXTURE_2D,
mode = VideoProcessMode.READ_WRITE,
stage = VideoStage.RENDER_PRE_DISPLAY
)
override fun processTexture(input: VideoTextureFrame, outputTextureId: Int) {
// 将后处理结果写入 outputTextureId
}
})
```
### 7.5 播放 API 速览(含 Demo 未覆盖) ### 7.5 播放 API 速览(含 Demo 未覆盖)
创建与渲染: 创建与渲染:
@@ -634,6 +713,8 @@ player.delegate = object : SellyLiveVideoPlayerDelegate {
- `setRenderSurfaceTexture(surfaceTexture, width, height)`:绑定 `SurfaceTexture`(调用方负责 SurfaceTexture 生命周期) - `setRenderSurfaceTexture(surfaceTexture, width, height)`:绑定 `SurfaceTexture`(调用方负责 SurfaceTexture 生命周期)
- `clearRenderTarget()`:解绑当前渲染面 - `clearRenderTarget()`:解绑当前渲染面
- `getRenderView()`:获取当前渲染 View - `getRenderView()`:获取当前渲染 View
- `setPlaybackFrameObserver(observer)`:设置播放侧只读 observertexture 路径)
- `setPlaybackVideoProcessor(processor)`:设置播放侧 processortexture 路径)
播放控制: 播放控制:
@@ -655,6 +736,12 @@ player.delegate = object : SellyLiveVideoPlayerDelegate {
- `setRenderView(surfaceView)` / `setRenderView(textureView)`:手动绑定现有 View - `setRenderView(surfaceView)` / `setRenderView(textureView)`:手动绑定现有 View
- `setRenderSurfaceTexture(surfaceTexture, width, height)`:高级场景使用 `SurfaceTexture`(调用方负责 SurfaceTexture 生命周期) - `setRenderSurfaceTexture(surfaceTexture, width, height)`:高级场景使用 `SurfaceTexture`(调用方负责 SurfaceTexture 生命周期)
- `clearRenderTarget()`:解绑当前渲染面但不一定立即销毁播放实例 - `clearRenderTarget()`:解绑当前渲染面但不一定立即销毁播放实例
- `setPlaybackFrameObserver(observer)` / `setPlaybackVideoProcessor(processor)`:点播同样支持 texture-backed playback processing
补充说明:
- 点播在重绑 `TextureView / SurfaceTexture` 后,会自动复用最近一次视频宽高信息,保持正确显示比例
- 如在已有 texture 目标上新增或移除 observer / processor也需要重绑一次 texture render target 才会应用新的渲染模式
因此 Demo 中点播页的 `SurfaceView / TextureView` 选择,也与直播播放页保持一致,均在首页设置中统一生效。 因此 Demo 中点播页的 `SurfaceView / TextureView` 选择,也与直播播放页保持一致,均在首页设置中统一生效。
@@ -681,6 +768,10 @@ player.delegate = object : SellyLiveVideoPlayerDelegate {
- 推流前先完成采集预览 - 推流前先完成采集预览
- `SurfaceView / TextureView` backend 建议在开始推流或播放前选定 - `SurfaceView / TextureView` backend 建议在开始推流或播放前选定
- Flutter 场景优先使用 `setRenderSurfaceTexture(...)`,不要把 `Hybrid Composition + SurfaceView` 当成默认方案
- 普通播放默认保持 `DIRECT`;只有确实需要播放侧帧观察或纹理后处理时,再启用 playback processing
- playback processing 当前仅建议用于 `TextureView / SurfaceTexture + TEXTURE_2D + RENDER_PRE_DISPLAY`
- 变更 texture 路径的 observer / processor 后,显式重绑一次 render target
- `RTC/WHIP` 的美颜、滤镜、水印、观测优先使用 `TEXTURE_2D` - `RTC/WHIP` 的美颜、滤镜、水印、观测优先使用 `TEXTURE_2D`
- `I420 / RGBA` 仅在算法必须访问 CPU 像素时再使用 - `I420 / RGBA` 仅在算法必须访问 CPU 像素时再使用
- 完整重写输出的 GPU 处理器设置 `fullRewrite = true`;叠加类处理保留默认值 - 完整重写输出的 GPU 处理器设置 `fullRewrite = true`;叠加类处理保留默认值
@@ -723,13 +814,13 @@ SDK 不解析 URL 中的鉴权信息,所有鉴权均通过 `token` 属性完
- 普通原生 Android 页面,优先使用默认 `SurfaceView`,性能最优 - 普通原生 Android 页面,优先使用默认 `SurfaceView`,性能最优
- 需要与按钮、封面、弹层等普通 View 正常混排时,优先使用 `TextureView` - 需要与按钮、封面、弹层等普通 View 正常混排时,优先使用 `TextureView`
- Flutter 场景通过 `setRenderSurfaceTexture()` 接入,`TextureView` 同一套渲染管线 - Flutter 场景通过 `setRenderSurfaceTexture()` 接入,配合 Flutter `Texture` widget 使用
- 当前版本建议在开始推流/播放前选定 backend当前 Demo 在首页设置中统一选择,进入页面后不支持切换 - 当前版本建议在开始推流/播放前选定 backend当前 Demo 在首页设置中统一选择,进入页面后不支持切换
### Q5.1`TextureView` 模式下VOD/RTMP 播放的 `BufferQueueProducer timeout` 日志是什么? ### Q5.1`TextureView` 模式下VOD/RTMP 播放的 `BufferQueueProducer timeout` 日志是什么?
**A** **A**
SDK 内部使用 GL Bridge 将 MediaCodec 硬解输出通过 OpenGL 中转渲染到 TextureView大幅减少此类日志。如在极端场景下仍偶现属于 Android 系统 BufferQueue 机制限制,不影响播放功能。`SurfaceView` 路径不存在此问题 当前 `RTMP/VOD``TextureView / SurfaceTexture` 默认走 direct output以缩短首帧和减少黑屏。极端机型或系统版本下仍可能偶现 `BufferQueueProducer timeout` / `BufferQueue has been abandoned` 之类系统日志;如果不伴随黑屏、花屏、卡死,通常可视为 Android BufferQueue 机制噪声。开启 playback processing 时texture 路径内部会启用额外的处理中转链,日志形态也可能与 direct 模式不同
### Q5.2`attach` 和 `set` 两套 API 的区别? ### Q5.2`attach` 和 `set` 两套 API 的区别?
**A** **A**

Binary file not shown.

View File

@@ -171,7 +171,7 @@ class FeatureHubActivity : AppCompatActivity() {
if (dy <= 0) return if (dy <= 0) return
val lastVisible = layoutManager.findLastVisibleItemPosition() val lastVisible = layoutManager.findLastVisibleItemPosition()
if (lastVisible >= aliveAdapter.itemCount - 2) { if (lastVisible >= aliveAdapter.itemCount - 2) {
appendNextPage() recyclerView.post { appendNextPage() }
} }
} }
}) })
@@ -243,12 +243,14 @@ class FeatureHubActivity : AppCompatActivity() {
} }
val url = item.url?.trim().orEmpty() val url = item.url?.trim().orEmpty()
val xorKey = item.xorKey.orEmpty()
val intent = if (url.isNotEmpty()) { val intent = if (url.isNotEmpty()) {
LivePlayActivity.createIntent( LivePlayActivity.createIntent(
this, this,
resolvePlayModeFromUrl(url), resolvePlayModeFromUrl(url),
url, url,
autoStart = true autoStart = true,
xorKeyHex = xorKey
) )
} else { } else {
val liveMode = resolvePlayMode(item.playProtocol) val liveMode = resolvePlayMode(item.playProtocol)
@@ -263,7 +265,8 @@ class FeatureHubActivity : AppCompatActivity() {
params.vhost, params.vhost,
params.appName, params.appName,
params.streamName, params.streamName,
autoStart = true autoStart = true,
xorKeyHex = xorKey
) )
}.apply { }.apply {
item.previewImage?.let { putExtra(LivePlayActivity.EXTRA_PREVIEW_IMAGE_URL, it) } item.previewImage?.let { putExtra(LivePlayActivity.EXTRA_PREVIEW_IMAGE_URL, it) }

View File

@@ -45,6 +45,9 @@ import com.demo.SellyCloudSDK.live.env.normalizedAppName
import com.demo.SellyCloudSDK.live.env.normalizedVhost import com.demo.SellyCloudSDK.live.env.normalizedVhost
import com.demo.SellyCloudSDK.live.env.toLiveMode import com.demo.SellyCloudSDK.live.env.toLiveMode
import com.demo.SellyCloudSDK.live.util.GalleryImageSaver import com.demo.SellyCloudSDK.live.util.GalleryImageSaver
import com.demo.SellyCloudSDK.playback.PlaybackProcessingPreset
import com.demo.SellyCloudSDK.playback.PlaybackTextureObserverDemo
import com.demo.SellyCloudSDK.playback.PlaybackTexturePatchProcessor
import com.sellycloud.sellycloudsdk.SellyLatencyChasingUpdate import com.sellycloud.sellycloudsdk.SellyLatencyChasingUpdate
import com.sellycloud.sellycloudsdk.SellyLiveMode import com.sellycloud.sellycloudsdk.SellyLiveMode
import com.sellycloud.sellycloudsdk.SellyLiveVideoPlayer import com.sellycloud.sellycloudsdk.SellyLiveVideoPlayer
@@ -89,6 +92,9 @@ class LivePlayActivity : AppCompatActivity() {
private var lastLatencyChasingUpdate: SellyLatencyChasingUpdate? = null private var lastLatencyChasingUpdate: SellyLatencyChasingUpdate? = null
private var hasReleasedPlayer: Boolean = false private var hasReleasedPlayer: Boolean = false
private var logEnabled: Boolean = true private var logEnabled: Boolean = true
private var processingPreset: PlaybackProcessingPreset = PlaybackProcessingPreset.DIRECT
private var renderTargetRebindCount: Int = 0
private var lastRenderTargetRebindCostMs: Long? = null
private val logLines: ArrayDeque<String> = ArrayDeque() private val logLines: ArrayDeque<String> = ArrayDeque()
private val logTimeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) private val logTimeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
@@ -96,6 +102,14 @@ class LivePlayActivity : AppCompatActivity() {
private var logSummaryView: TextView? = null private var logSummaryView: TextView? = null
private var logContentView: TextView? = null private var logContentView: TextView? = null
private var logFloatingButton: View? = null private var logFloatingButton: View? = null
private var toolsFloatingButton: View? = null
private val playbackObserverDemo by lazy(LazyThreadSafetyMode.NONE) {
PlaybackTextureObserverDemo(::logEvent)
}
private val playbackPatchProcessor by lazy(LazyThreadSafetyMode.NONE) {
PlaybackTexturePatchProcessor(::logEvent)
}
private val storagePermissionLauncher = registerForActivityResult( private val storagePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
@@ -111,6 +125,7 @@ class LivePlayActivity : AppCompatActivity() {
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.hide() supportActionBar?.hide()
addLogFloatingButton() addLogFloatingButton()
addToolsFloatingButton()
envStore = LiveEnvSettingsStore(this) envStore = LiveEnvSettingsStore(this)
useTextureView = AvDemoSettingsStore(this).read().renderBackendPreference.isTextureView() useTextureView = AvDemoSettingsStore(this).read().renderBackendPreference.isTextureView()
@@ -221,8 +236,9 @@ class LivePlayActivity : AppCompatActivity() {
binding.actionScreenshot.setOnClickListener { captureCurrentFrame() } binding.actionScreenshot.setOnClickListener { captureCurrentFrame() }
binding.actionPip.setOnClickListener { enterPipMode() } binding.actionPip.setOnClickListener { enterPipMode() }
val backend = if (useTextureView) RenderBackend.TEXTURE_VIEW else RenderBackend.SURFACE_VIEW val backend = currentRenderBackend()
playerClient.attachRenderView(binding.renderContainer, backend) playerClient.attachRenderView(binding.renderContainer, backend)
logEvent("渲染目标已绑定: backend=${currentRenderBackendLabel()}, processing=${processingPreset.label}")
if (args.autoStart) { if (args.autoStart) {
lifecycleScope.launch { lifecycleScope.launch {
@@ -429,6 +445,7 @@ class LivePlayActivity : AppCompatActivity() {
binding.controlBar.visibility = controlsVisibility binding.controlBar.visibility = controlsVisibility
binding.btnClose.visibility = controlsVisibility binding.btnClose.visibility = controlsVisibility
logFloatingButton?.visibility = controlsVisibility logFloatingButton?.visibility = controlsVisibility
toolsFloatingButton?.visibility = controlsVisibility
if (isInPip) { if (isInPip) {
binding.ivPreview.visibility = View.GONE binding.ivPreview.visibility = View.GONE
} else { } else {
@@ -547,6 +564,99 @@ class LivePlayActivity : AppCompatActivity() {
logFloatingButton = button logFloatingButton = button
} }
private fun addToolsFloatingButton() {
val sizePx = dpToPx(44)
val marginEndPx = dpToPx(72)
val controlBarHeight = resources.getDimensionPixelSize(R.dimen.av_control_bar_height)
val marginBottomPx = controlBarHeight + dpToPx(16)
val bgDrawable = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(
Color.parseColor("#B33B0764"),
Color.parseColor("#803B0764")
)).apply {
shape = GradientDrawable.OVAL
setStroke(dpToPx(1), Color.parseColor("#55FFFFFF"))
}
val button = AppCompatTextView(this).apply {
text = ""
setTextColor(Color.parseColor("#F8FAFC"))
textSize = 11f
gravity = Gravity.CENTER
background = bgDrawable
elevation = dpToPx(4).toFloat()
setShadowLayer(2f, 0f, 1f, Color.parseColor("#66000000"))
isClickable = true
isFocusable = true
contentDescription = "播放处理与回归工具"
setOnClickListener { showPlaybackToolsDialog() }
}
val params = FrameLayout.LayoutParams(sizePx, sizePx).apply {
gravity = Gravity.END or Gravity.BOTTOM
marginEnd = marginEndPx
bottomMargin = marginBottomPx
}
addContentView(button, params)
toolsFloatingButton = button
}
private fun showPlaybackToolsDialog() {
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dpToPx(20), dpToPx(16), dpToPx(20), dpToPx(8))
}
val summary = TextView(this).apply {
text = "当前后端: ${currentRenderBackendLabel()}\n" +
"当前协议: ${args.liveMode.name}\n" +
"当前模式: ${processingPreset.label}\n" +
"说明: processing 仅支持 RTMP + TextureView。"
setTextColor(Color.parseColor("#E5E7EB"))
textSize = 13f
}
container.addView(summary)
container.addView(spaceView(dpToPx(12)))
container.addView(createToolActionButton("切换 DIRECT 直出") {
applyPlaybackProcessingPreset(PlaybackProcessingPreset.DIRECT, trigger = "工具面板")
})
container.addView(createToolActionButton("切换 PROCESSING Observer") {
applyPlaybackProcessingPreset(PlaybackProcessingPreset.OBSERVER, trigger = "工具面板")
})
container.addView(createToolActionButton("切换 PROCESSING 红块 Processor") {
applyPlaybackProcessingPreset(PlaybackProcessingPreset.PROCESSOR, trigger = "工具面板")
})
container.addView(createToolActionButton("仅重绑当前目标") {
rebindRenderTarget("手动回归")
})
AlertDialog.Builder(this)
.setTitle("播放处理 / 目标重绑")
.setView(container)
.setNegativeButton("关闭", null)
.show()
}
private fun createToolActionButton(label: String, onClick: () -> Unit): View {
return AppCompatTextView(this).apply {
text = label
gravity = Gravity.CENTER
textSize = 14f
setTextColor(Color.parseColor("#F8FAFC"))
background = GradientDrawable().apply {
cornerRadius = dpToPx(10).toFloat()
setColor(Color.parseColor("#334155"))
setStroke(dpToPx(1), Color.parseColor("#475569"))
}
setPadding(dpToPx(12), dpToPx(12), dpToPx(12), dpToPx(12))
isClickable = true
isFocusable = true
setOnClickListener { onClick() }
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
bottomMargin = dpToPx(10)
}
}
}
private fun showLogDialog() { private fun showLogDialog() {
if (logDialog?.isShowing == true) { if (logDialog?.isShowing == true) {
refreshLogDialogContent() refreshLogDialogContent()
@@ -680,10 +790,20 @@ class LivePlayActivity : AppCompatActivity() {
builder.append("streamName: ").append(params.streamName).append('\n') builder.append("streamName: ").append(params.streamName).append('\n')
} }
builder.append("当前状态: ").append(formatState(currentState)).append('\n') builder.append("当前状态: ").append(formatState(currentState)).append('\n')
builder.append("渲染后端: ").append(currentRenderBackendLabel()).append('\n')
builder.append("播放处理: ").append(processingPreset.label).append('\n')
builder.append("是否播放中: ").append(if (isPlaying) "" else "").append('\n') builder.append("是否播放中: ").append(if (isPlaying) "" else "").append('\n')
builder.append("是否静音: ").append(if (isMuted) "" else "").append('\n') builder.append("是否静音: ").append(if (isMuted) "" else "").append('\n')
builder.append("首帧视频耗时(ms): ").append(firstVideoFrameCostMs ?: "未统计").append('\n') builder.append("首帧视频耗时(ms): ").append(firstVideoFrameCostMs ?: "未统计").append('\n')
builder.append("首帧音频耗时(ms): ").append(firstAudioFrameCostMs ?: "未统计").append('\n') builder.append("首帧音频耗时(ms): ").append(firstAudioFrameCostMs ?: "未统计").append('\n')
builder.append("目标重绑次数: ").append(renderTargetRebindCount).append('\n')
builder.append("最近重绑耗时(ms): ").append(lastRenderTargetRebindCostMs ?: "未统计").append('\n')
val processingDetail = when (processingPreset) {
PlaybackProcessingPreset.DIRECT -> "processing: 关闭"
PlaybackProcessingPreset.OBSERVER -> playbackObserverDemo.summary()
PlaybackProcessingPreset.PROCESSOR -> playbackPatchProcessor.summary()
}
builder.append(processingDetail).append('\n')
val attemptElapsed = playAttemptStartElapsedMs?.let { SystemClock.elapsedRealtime() - it } val attemptElapsed = playAttemptStartElapsedMs?.let { SystemClock.elapsedRealtime() - it }
if (attemptElapsed == null) { if (attemptElapsed == null) {
builder.append("本次播放已耗时(ms): 未开始").append('\n') builder.append("本次播放已耗时(ms): 未开始").append('\n')
@@ -752,7 +872,81 @@ class LivePlayActivity : AppCompatActivity() {
isLatencyChasingActive = false isLatencyChasingActive = false
lastLatencyChasingSpeed = null lastLatencyChasingSpeed = null
lastLatencyChasingUpdate = null lastLatencyChasingUpdate = null
logEvent("播放尝试开始") logEvent("播放尝试开始: backend=${currentRenderBackendLabel()}, processing=${processingPreset.label}")
}
private fun applyPlaybackProcessingPreset(preset: PlaybackProcessingPreset, trigger: String) {
if (preset == processingPreset) {
logEvent("播放处理保持不变: ${preset.label}, trigger=$trigger")
Toast.makeText(this, "当前已是 ${preset.label}", Toast.LENGTH_SHORT).show()
return
}
if (args.liveMode != SellyLiveMode.RTMP && preset != PlaybackProcessingPreset.DIRECT) {
logEvent("播放处理切换被拒绝: liveMode=${args.liveMode.name} 当前仅支持 RTMP")
Toast.makeText(this, "当前 demo 仅支持 RTMP 播放 processing", Toast.LENGTH_SHORT).show()
return
}
if (!useTextureView && preset != PlaybackProcessingPreset.DIRECT) {
logEvent("播放处理切换被拒绝: backend=${currentRenderBackendLabel()} 不支持 ${preset.label}")
Toast.makeText(this, "播放 processing 仅支持 TextureView 后端", Toast.LENGTH_SHORT).show()
return
}
processingPreset = preset
configurePlaybackProcessing()
logEvent("播放处理切换: mode=${preset.label}, trigger=$trigger")
rebindRenderTarget("processing_${preset.name.lowercase(Locale.US)}")
Toast.makeText(this, "已切到 ${preset.label}", Toast.LENGTH_SHORT).show()
}
private fun configurePlaybackProcessing() {
when (processingPreset) {
PlaybackProcessingPreset.DIRECT -> {
playerClient.setPlaybackFrameObserver(null)
playerClient.setPlaybackVideoProcessor(null)
}
PlaybackProcessingPreset.OBSERVER -> {
playerClient.setPlaybackVideoProcessor(null)
playerClient.setPlaybackFrameObserver(playbackObserverDemo)
}
PlaybackProcessingPreset.PROCESSOR -> {
playerClient.setPlaybackFrameObserver(null)
playerClient.setPlaybackVideoProcessor(playbackPatchProcessor)
}
}
}
private fun rebindRenderTarget(reason: String) {
if (hasReleasedPlayer) return
val shouldResumePlayback = currentState == SellyPlayerState.Playing ||
currentState == SellyPlayerState.Connecting ||
currentState == SellyPlayerState.Reconnecting
val startedAtMs = SystemClock.elapsedRealtime()
val backend = currentRenderBackend()
logEvent("目标重绑开始: reason=$reason, backend=${currentRenderBackendLabel()}, processing=${processingPreset.label}")
playerClient.clearRenderTarget()
playerClient.attachRenderView(binding.renderContainer, backend)
if (shouldResumePlayback) {
logEvent("目标重绑后恢复播放: previousState=${formatState(currentState)}")
startPlayAttempt()
resetPreviewForPlayback()
playerClient.prepareToPlay()
playerClient.play()
} else if (currentState == SellyPlayerState.Paused) {
logEvent("目标重绑完成: 当前处于暂停态,变更将在下次播放时生效")
}
val costMs = SystemClock.elapsedRealtime() - startedAtMs
renderTargetRebindCount += 1
lastRenderTargetRebindCostMs = costMs
logEvent("目标重绑完成: count=$renderTargetRebindCount, cost=${costMs}ms")
}
private fun currentRenderBackend(): RenderBackend {
return if (useTextureView) RenderBackend.TEXTURE_VIEW else RenderBackend.SURFACE_VIEW
}
private fun currentRenderBackendLabel(): String {
return if (useTextureView) "TextureView" else "SurfaceView"
} }
private fun formatLatencyChasingSpeed(speed: Float): String { private fun formatLatencyChasingSpeed(speed: Float): String {
@@ -840,7 +1034,8 @@ class LivePlayActivity : AppCompatActivity() {
vhost: String, vhost: String,
appName: String, appName: String,
streamName: String, streamName: String,
autoStart: Boolean = true autoStart: Boolean = true,
xorKeyHex: String = ""
): Intent { ): Intent {
return Intent(context, LivePlayActivity::class.java) return Intent(context, LivePlayActivity::class.java)
.putExtra(EXTRA_PLAY_PROTOCOL, liveMode.name) .putExtra(EXTRA_PLAY_PROTOCOL, liveMode.name)
@@ -848,6 +1043,7 @@ class LivePlayActivity : AppCompatActivity() {
.putExtra(EXTRA_PLAY_APP_NAME, appName) .putExtra(EXTRA_PLAY_APP_NAME, appName)
.putExtra(EXTRA_PLAY_STREAM_NAME, streamName) .putExtra(EXTRA_PLAY_STREAM_NAME, streamName)
.putExtra(EXTRA_AUTO_START, autoStart) .putExtra(EXTRA_AUTO_START, autoStart)
.putExtra(EXTRA_XOR_KEY_HEX, xorKeyHex)
} }
fun closePipIfAny(): Boolean { fun closePipIfAny(): Boolean {
@@ -908,7 +1104,8 @@ class LivePlayActivity : AppCompatActivity() {
val input = intent.getStringExtra(EXTRA_STREAM_ID_OR_URL).orEmpty() val input = intent.getStringExtra(EXTRA_STREAM_ID_OR_URL).orEmpty()
.ifBlank { playParams?.streamName ?: env.defaultStreamId } .ifBlank { playParams?.streamName ?: env.defaultStreamId }
val autoStart = intent.getBooleanExtra(EXTRA_AUTO_START, true) val autoStart = intent.getBooleanExtra(EXTRA_AUTO_START, true)
val xorKeyHex = intent.getStringExtra(EXTRA_XOR_KEY_HEX).orEmpty().trim() val rawXorKey = intent.getStringExtra(EXTRA_XOR_KEY_HEX).orEmpty().trim()
val xorKeyHex = sanitizeXorKeyHex(rawXorKey)
val mode = resolveLiveMode(rawProtocol, input, env) val mode = resolveLiveMode(rawProtocol, input, env)
return Args( return Args(
liveMode = mode, liveMode = mode,
@@ -920,6 +1117,22 @@ class LivePlayActivity : AppCompatActivity() {
) )
} }
private val HEX_REGEX = Regex("^[0-9a-fA-F]+$")
/**
* Validate and normalize the XOR key. Returns empty string if invalid
* to prevent native crash from malformed keys.
*/
private fun sanitizeXorKeyHex(raw: String): String {
if (raw.isBlank()) return ""
val hex = if (raw.startsWith("0x", ignoreCase = true)) raw.substring(2) else raw
if (hex.isEmpty() || hex.length % 2 != 0 || !HEX_REGEX.matches(hex)) {
android.util.Log.w("LivePlayActivity", "Invalid xorKeyHex '$raw', ignoring to prevent crash")
return ""
}
return hex
}
private fun resolveLiveMode(raw: String?, input: String, env: LiveEnvSettings): SellyLiveMode { private fun resolveLiveMode(raw: String?, input: String, env: LiveEnvSettings): SellyLiveMode {
val normalized = raw?.trim()?.uppercase() val normalized = raw?.trim()?.uppercase()
val modeFromExtra = when (normalized) { val modeFromExtra = when (normalized) {

View File

@@ -37,7 +37,10 @@ import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper
import com.demo.SellyCloudSDK.live.auth.LiveTokenSigner import com.demo.SellyCloudSDK.live.auth.LiveTokenSigner
import com.demo.SellyCloudSDK.live.env.LiveEnvSettingsStore import com.demo.SellyCloudSDK.live.env.LiveEnvSettingsStore
import com.demo.SellyCloudSDK.live.env.applyToSdkRuntimeConfig import com.demo.SellyCloudSDK.live.env.applyToSdkRuntimeConfig
import com.demo.SellyCloudSDK.live.env.normalizedAppName
import com.demo.SellyCloudSDK.live.env.normalizedVhost
import com.demo.SellyCloudSDK.live.env.toLiveMode import com.demo.SellyCloudSDK.live.env.toLiveMode
import com.demo.SellyCloudSDK.live.square.StreamXorRepository
import com.demo.SellyCloudSDK.live.util.GalleryImageSaver import com.demo.SellyCloudSDK.live.util.GalleryImageSaver
import com.sellycloud.sellycloudsdk.CpuUsage import com.sellycloud.sellycloudsdk.CpuUsage
import com.sellycloud.sellycloudsdk.Disposable import com.sellycloud.sellycloudsdk.Disposable
@@ -85,6 +88,7 @@ class LivePushActivity : AppCompatActivity() {
private var useTextureView: Boolean = false private var useTextureView: Boolean = false
private var isPublishing: Boolean = false private var isPublishing: Boolean = false
private var hasReportedXor: Boolean = false
private var isStatsCollapsed: Boolean = false private var isStatsCollapsed: Boolean = false
private var latestStats: SellyLivePusherStats? = null private var latestStats: SellyLivePusherStats? = null
private var isMuted: Boolean = false private var isMuted: Boolean = false
@@ -361,11 +365,109 @@ class LivePushActivity : AppCompatActivity() {
updateLayoutForOrientationAndState() updateLayoutForOrientationAndState()
updateStatusPanel() updateStatusPanel()
updateStatsFromStats(latestStats) updateStatsFromStats(latestStats)
if (state == SellyLiveStatus.Publishing && !hasReportedXor) {
reportXorKeyToServer()
}
if (state == SellyLiveStatus.Stopped) { if (state == SellyLiveStatus.Stopped) {
if (hasReportedXor) {
clearXorKeyOnServer()
}
hasReportedXor = false
navigateHomeAfterStop() navigateHomeAfterStop()
} }
} }
/**
* Report the current XOR key state to the server after push starts.
* Always reports — empty xorKey means "disable & clear cached key".
*/
private fun reportXorKeyToServer() {
val settings = settingsStore.read()
val env = envStore.read()
val xorKey = settings.xorKeyHex
// Resolve actual stream/app/vhost from the push target
val streamTarget = if (settings.useUrlMode) {
parseRtmpUrl(settings.pushUrl)
} else {
StreamTarget(
stream = settings.streamId,
app = env.normalizedAppName(),
vhost = env.normalizedVhost()
)
}
if (streamTarget == null || streamTarget.stream.isBlank()) {
debugLog("XOR report skipped: cannot resolve stream target")
return
}
lastReportedStreamTarget = streamTarget
lifecycleScope.launch {
val ok = StreamXorRepository.reportXorKey(
stream = streamTarget.stream,
app = streamTarget.app,
vhost = streamTarget.vhost,
xorKey = xorKey
)
if (ok) {
hasReportedXor = true
}
debugLog("XOR report: stream=${streamTarget.stream}, app=${streamTarget.app}, key=$xorKey -> $ok")
}
}
/**
* Best-effort clear of the XOR key on stop.
*/
private fun clearXorKeyOnServer() {
val target = lastReportedStreamTarget ?: return
lifecycleScope.launch {
val ok = StreamXorRepository.reportXorKey(
stream = target.stream,
app = target.app,
vhost = target.vhost,
xorKey = ""
)
debugLog("XOR clear: stream=${target.stream} -> $ok")
}
}
private var lastReportedStreamTarget: StreamTarget? = null
private data class StreamTarget(val stream: String, val app: String, val vhost: String?)
/**
* Parse vhost/app/stream from an RTMP URL like:
* rtmp://host:port/app/stream?token=...&vhost=xxx
*/
private fun parseRtmpUrl(url: String): StreamTarget? {
try {
val trimmed = url.trim()
if (!trimmed.lowercase().startsWith("rtmp://")) return null
// Strip scheme
val withoutScheme = trimmed.substringAfter("://")
// Extract host (authority) before first /
val host = withoutScheme.substringBefore("/").substringBefore(":").takeIf { it.isNotBlank() }
val pathAndQuery = withoutScheme.substringAfter("/", "")
if (pathAndQuery.isBlank()) return null
val pathPart = pathAndQuery.substringBefore("?")
val segments = pathPart.split("/").filter { it.isNotBlank() }
if (segments.size < 2) return null
val app = segments[0]
val stream = segments[1]
// Extract vhost from query params; fallback to URL host
val query = pathAndQuery.substringAfter("?", "")
val queryVhost = query.split("&")
.firstOrNull { it.startsWith("vhost=") }
?.substringAfter("vhost=")
?.takeIf { it.isNotBlank() }
val vhost = queryVhost ?: host
return StreamTarget(stream = stream, app = app, vhost = vhost)
} catch (_: Exception) {
return null
}
}
private fun navigateHomeAfterStop() { private fun navigateHomeAfterStop() {
if (hasNavigatedHome || isFinishing || isDestroyed) return if (hasNavigatedHome || isFinishing || isDestroyed) return
hasNavigatedHome = true hasNavigatedHome = true
@@ -613,11 +715,8 @@ class LivePushActivity : AppCompatActivity() {
private fun toggleFrameInterceptor() { private fun toggleFrameInterceptor() {
frameInterceptorMode = when (frameInterceptorMode) { frameInterceptorMode = when (frameInterceptorMode) {
FrameInterceptorMode.OFF -> FrameInterceptorMode.OBSERVE FrameInterceptorMode.OFF -> FrameInterceptorMode.OBSERVE
FrameInterceptorMode.OBSERVE -> FrameInterceptorMode.CPU_EMPTY FrameInterceptorMode.OBSERVE -> FrameInterceptorMode.OFF
FrameInterceptorMode.CPU_EMPTY -> FrameInterceptorMode.CPU_SINGLE else -> FrameInterceptorMode.OBSERVE
FrameInterceptorMode.CPU_SINGLE -> FrameInterceptorMode.CPU_DOUBLE
FrameInterceptorMode.CPU_DOUBLE -> FrameInterceptorMode.OFF
FrameInterceptorMode.MODIFY -> FrameInterceptorMode.OBSERVE
} }
resetFrameCallbackWindow(if (frameInterceptorMode == FrameInterceptorMode.OFF) "off" else frameInterceptorMode.label) resetFrameCallbackWindow(if (frameInterceptorMode == FrameInterceptorMode.OFF) "off" else frameInterceptorMode.label)
applyFrameInterceptorState() applyFrameInterceptorState()
@@ -677,46 +776,12 @@ class LivePushActivity : AppCompatActivity() {
}) })
} }
FrameInterceptorMode.CPU_EMPTY -> { FrameInterceptorMode.CPU_EMPTY,
addFrameObserver(activePusher, object : VideoFrameObserver { FrameInterceptorMode.CPU_SINGLE,
override val config: VideoFrameObserverConfig = VideoFrameObserverConfig(
preferredFormat = VideoProcessFormat.I420
)
})
}
FrameInterceptorMode.CPU_SINGLE -> {
addFrameObserver(activePusher, object : VideoFrameObserver {
override val config: VideoFrameObserverConfig = VideoFrameObserverConfig(
preferredFormat = VideoProcessFormat.I420
)
override fun onFrame(frame: SellyVideoFrame) {
recordCpuObserverFrame(mode = frameInterceptorMode, frame = frame)
}
})
}
FrameInterceptorMode.CPU_DOUBLE -> { FrameInterceptorMode.CPU_DOUBLE -> {
addFrameObserver(activePusher, object : VideoFrameObserver { // CPU observer modes removed from default toggle.
override val config: VideoFrameObserverConfig = VideoFrameObserverConfig( // SDK still supports I420/RGBA observers; these were disabled in Demo
preferredFormat = VideoProcessFormat.I420 // because the current pure-Kotlin color conversion is too slow for production.
)
override fun onFrame(frame: SellyVideoFrame) {
recordCpuObserverFrame(mode = frameInterceptorMode, frame = frame)
}
})
addFrameObserver(activePusher, object : VideoFrameObserver {
override val config: VideoFrameObserverConfig = VideoFrameObserverConfig(
preferredFormat = VideoProcessFormat.I420
)
override fun onFrame(frame: SellyVideoFrame) {
// Intentionally empty. This verifies that multiple CPU observers
// sharing the same format do not force duplicate conversion work.
}
})
} }
FrameInterceptorMode.MODIFY -> { FrameInterceptorMode.MODIFY -> {
@@ -1389,8 +1454,13 @@ class LivePushActivity : AppCompatActivity() {
} }
val editNs = System.nanoTime() - editStartNs val editNs = System.nanoTime() - editStartNs
// Return a new SellyVideoFrame wrapping the same (modified) buffer.
// SDK checks identity (currentCpuFrame !== inputCpuFrame) to decide whether to upload;
// returning the same object would skip the upload even though the buffer was modified.
frame.buffer.retain()
val modifiedFrame = SellyVideoFrame(frame.buffer, frame.rotation, frame.timestampNs)
return ModifiedFrameTrace( return ModifiedFrameTrace(
frame = frame, frame = modifiedFrame,
bufferKind = bufferKind, bufferKind = bufferKind,
patchLabel = patchLabel, patchLabel = patchLabel,
editNs = editNs editNs = editNs

View File

@@ -28,7 +28,8 @@ data class AliveStreamItem(
val previewImage: String?, val previewImage: String?,
val durationSeconds: Long?, val durationSeconds: Long?,
val playProtocol: String?, val playProtocol: String?,
val streamPk: String? val streamPk: String?,
val xorKey: String? = null
) )
val AliveStreamItem.isPkStream: Boolean val AliveStreamItem.isPkStream: Boolean
@@ -101,6 +102,8 @@ private fun JSONObject.toAliveItem(): AliveStreamItem {
val streamPk = optString("stream_pk") val streamPk = optString("stream_pk")
.ifBlank { optString("streamPk") } .ifBlank { optString("streamPk") }
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
val xorKey = optString("xor_key")
.takeIf { it.isNotBlank() }
return AliveStreamItem( return AliveStreamItem(
vhost = vhost, vhost = vhost,
@@ -110,6 +113,7 @@ private fun JSONObject.toAliveItem(): AliveStreamItem {
previewImage = previewImage, previewImage = previewImage,
durationSeconds = durationSeconds, durationSeconds = durationSeconds,
playProtocol = playProtocol, playProtocol = playProtocol,
streamPk = streamPk streamPk = streamPk,
xorKey = xorKey
) )
} }

View File

@@ -53,6 +53,9 @@ class AliveStreamAdapter(
binding.tvStreamName.text = title binding.tvStreamName.text = title
binding.tvPkBadge.visibility = if (item.isPkStream) View.VISIBLE else View.GONE binding.tvPkBadge.visibility = if (item.isPkStream) View.VISIBLE else View.GONE
val hasXor = !item.xorKey.isNullOrBlank()
binding.tvXorBadge.visibility = if (hasXor) View.VISIBLE else View.GONE
if (hasXor) binding.tvXorBadge.text = "\uD83D\uDD12"
val protocol = item.playProtocol val protocol = item.playProtocol
?.trim() ?.trim()

View File

@@ -0,0 +1,57 @@
package com.demo.SellyCloudSDK.live.square
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private const val STREAM_XOR_URL = "http://rtmp.sellycloud.io:8089/live/sdk/demo/stream-xor"
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
/**
* Reports the XOR encryption state for a push stream.
*
* - `xorKey` non-empty: tells the server this stream uses XOR encryption with this key.
* - `xorKey` empty/null: clears the cached key on the server.
*
* The server caches the key in memory. When `GET /live/sdk/alive-list` returns, matching
* streams will include the `xor_key` field so viewers can auto-decrypt.
*/
object StreamXorRepository {
private val client = OkHttpClient()
/**
* @param stream Stream name (required).
* @param app Application name (required, e.g. "live").
* @param vhost Virtual host (optional).
* @param xorKey XOR hex key. Empty string or null means "disable & clear".
* @return `true` if the server accepted the request.
*/
suspend fun reportXorKey(
stream: String,
app: String,
vhost: String? = null,
xorKey: String?
): Boolean = withContext(Dispatchers.IO) {
try {
val body = JSONObject().apply {
put("stream", stream)
put("app", app)
if (!vhost.isNullOrBlank()) put("vhost", vhost)
put("xor_key", xorKey.orEmpty())
}
val request = Request.Builder()
.url(STREAM_XOR_URL)
.post(body.toString().toRequestBody(JSON_MEDIA_TYPE))
.build()
client.newCall(request).execute().use { response ->
response.isSuccessful
}
} catch (_: Exception) {
false
}
}
}

View File

@@ -0,0 +1,192 @@
package com.demo.SellyCloudSDK.playback
import android.opengl.GLES20
import android.os.SystemClock
import com.sellycloud.sellycloudsdk.PlaybackFrameObserver
import com.sellycloud.sellycloudsdk.PlaybackFrameObserverConfig
import com.sellycloud.sellycloudsdk.PlaybackVideoProcessor
import com.sellycloud.sellycloudsdk.PlaybackVideoProcessorConfig
import com.sellycloud.sellycloudsdk.VideoProcessFormat
import com.sellycloud.sellycloudsdk.VideoProcessMode
import com.sellycloud.sellycloudsdk.VideoStage
import com.sellycloud.sellycloudsdk.VideoTextureFrame
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
enum class PlaybackProcessingPreset(val label: String) {
DIRECT("DIRECT"),
OBSERVER("PROCESSING_OBSERVER"),
PROCESSOR("PROCESSING_PROCESSOR")
}
class PlaybackTextureObserverDemo(
private val log: (String) -> Unit
) : PlaybackFrameObserver {
override val config: PlaybackFrameObserverConfig = PlaybackFrameObserverConfig(
preferredFormat = VideoProcessFormat.TEXTURE_2D,
stage = VideoStage.RENDER_PRE_DISPLAY
)
@Volatile
private var lastFrameWidth: Int = 0
@Volatile
private var lastFrameHeight: Int = 0
@Volatile
private var lastFrameRotation: Int = 0
@Volatile
private var lastFps: Float = 0f
private val frameCounter = AtomicInteger(0)
private val windowStartMs = AtomicLong(0L)
override fun onGlContextCreated() {
log("processing observer: GL context created")
}
override fun onGlContextDestroyed() {
log("processing observer: GL context destroyed")
}
override fun onTextureFrame(frame: VideoTextureFrame) {
lastFrameWidth = frame.width
lastFrameHeight = frame.height
lastFrameRotation = frame.rotation
val nowMs = SystemClock.elapsedRealtime()
val startedAtMs = windowStartMs.updateAndGet { existing -> if (existing == 0L) nowMs else existing }
val count = frameCounter.incrementAndGet()
val elapsedMs = nowMs - startedAtMs
if (elapsedMs < 1_000L) return
lastFps = count * 1000f / elapsedMs
frameCounter.set(0)
windowStartMs.set(nowMs)
log(
"processing observer: texture fps=${String.format(Locale.US, "%.1f", lastFps)}, " +
"size=${frame.width}x${frame.height}, rotation=${frame.rotation}"
)
}
fun summary(): String {
if (lastFrameWidth <= 0 || lastFrameHeight <= 0) return "observer: 等待纹理帧"
return "observer: ${String.format(Locale.US, "%.1f", lastFps)}fps, ${lastFrameWidth}x${lastFrameHeight}, rot=$lastFrameRotation"
}
}
class PlaybackTexturePatchProcessor(
private val log: (String) -> Unit
) : PlaybackVideoProcessor {
override val config: PlaybackVideoProcessorConfig = PlaybackVideoProcessorConfig(
preferredFormat = VideoProcessFormat.TEXTURE_2D,
mode = VideoProcessMode.READ_WRITE,
stage = VideoStage.RENDER_PRE_DISPLAY,
fullRewrite = false
)
@Volatile
private var lastFrameWidth: Int = 0
@Volatile
private var lastFrameHeight: Int = 0
@Volatile
private var lastFrameRotation: Int = 0
@Volatile
private var lastPatchFps: Float = 0f
private val patchCounter = AtomicInteger(0)
private val windowStartMs = AtomicLong(0L)
private var framebuffer = 0
override fun onGlContextCreated() {
log("processing processor: GL context created")
}
override fun onGlContextDestroyed() {
if (framebuffer != 0) {
GLES20.glDeleteFramebuffers(1, intArrayOf(framebuffer), 0)
framebuffer = 0
}
log("processing processor: GL context destroyed")
}
override fun processTexture(input: VideoTextureFrame, outputTextureId: Int) {
if (outputTextureId <= 0) return
lastFrameWidth = input.width
lastFrameHeight = input.height
lastFrameRotation = input.rotation
val patchWidth = (input.width * 0.18f).toInt().coerceAtLeast(48)
val patchHeight = (input.height * 0.10f).toInt().coerceAtLeast(32)
ensureFramebuffer()
if (framebuffer == 0) return
val previousFramebuffer = IntArray(1)
val previousViewport = IntArray(4)
val scissorWasEnabled = GLES20.glIsEnabled(GLES20.GL_SCISSOR_TEST)
val previousClearColor = FloatArray(4)
GLES20.glGetIntegerv(GLES20.GL_FRAMEBUFFER_BINDING, previousFramebuffer, 0)
GLES20.glGetIntegerv(GLES20.GL_VIEWPORT, previousViewport, 0)
GLES20.glGetFloatv(GLES20.GL_COLOR_CLEAR_VALUE, previousClearColor, 0)
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, framebuffer)
GLES20.glFramebufferTexture2D(
GLES20.GL_FRAMEBUFFER,
GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D,
outputTextureId,
0
)
GLES20.glViewport(0, 0, input.width, input.height)
GLES20.glEnable(GLES20.GL_SCISSOR_TEST)
GLES20.glScissor(0, 0, patchWidth, patchHeight)
GLES20.glClearColor(0.98f, 0.20f, 0.24f, 1.0f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glFramebufferTexture2D(
GLES20.GL_FRAMEBUFFER,
GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D,
0,
0
)
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, previousFramebuffer[0])
GLES20.glViewport(previousViewport[0], previousViewport[1], previousViewport[2], previousViewport[3])
GLES20.glClearColor(
previousClearColor[0],
previousClearColor[1],
previousClearColor[2],
previousClearColor[3]
)
if (!scissorWasEnabled) {
GLES20.glDisable(GLES20.GL_SCISSOR_TEST)
}
val nowMs = SystemClock.elapsedRealtime()
val startedAtMs = windowStartMs.updateAndGet { existing -> if (existing == 0L) nowMs else existing }
val count = patchCounter.incrementAndGet()
val elapsedMs = nowMs - startedAtMs
if (elapsedMs < 1_000L) return
lastPatchFps = count * 1000f / elapsedMs
patchCounter.set(0)
windowStartMs.set(nowMs)
log(
"processing processor: red patch fps=${String.format(Locale.US, "%.1f", lastPatchFps)}, " +
"size=${input.width}x${input.height}, rotation=${input.rotation}"
)
}
fun summary(): String {
if (lastFrameWidth <= 0 || lastFrameHeight <= 0) return "processor: 等待纹理帧"
return "processor: ${String.format(Locale.US, "%.1f", lastPatchFps)}fps, ${lastFrameWidth}x${lastFrameHeight}, rot=$lastFrameRotation"
}
private fun ensureFramebuffer() {
if (framebuffer != 0) return
val framebuffers = IntArray(1)
GLES20.glGenFramebuffers(1, framebuffers, 0)
framebuffer = framebuffers[0]
}
}

View File

@@ -32,6 +32,9 @@ import com.demo.SellyCloudSDK.R
import com.demo.SellyCloudSDK.avdemo.AvDemoSettingsStore import com.demo.SellyCloudSDK.avdemo.AvDemoSettingsStore
import com.demo.SellyCloudSDK.databinding.ActivityVodPlayBinding import com.demo.SellyCloudSDK.databinding.ActivityVodPlayBinding
import com.demo.SellyCloudSDK.live.util.GalleryImageSaver import com.demo.SellyCloudSDK.live.util.GalleryImageSaver
import com.demo.SellyCloudSDK.playback.PlaybackProcessingPreset
import com.demo.SellyCloudSDK.playback.PlaybackTextureObserverDemo
import com.demo.SellyCloudSDK.playback.PlaybackTexturePatchProcessor
import com.sellycloud.sellycloudsdk.SellyCloudManager import com.sellycloud.sellycloudsdk.SellyCloudManager
import com.sellycloud.sellycloudsdk.SellyLiveError import com.sellycloud.sellycloudsdk.SellyLiveError
import com.sellycloud.sellycloudsdk.SellyPlayerState import com.sellycloud.sellycloudsdk.SellyPlayerState
@@ -70,6 +73,9 @@ class VodPlayActivity : AppCompatActivity() {
private var firstAudioFrameElapsedMs: Long? = null private var firstAudioFrameElapsedMs: Long? = null
private var firstAudioFrameCostMs: Long? = null private var firstAudioFrameCostMs: Long? = null
private var bufferingActive = false private var bufferingActive = false
private var processingPreset: PlaybackProcessingPreset = PlaybackProcessingPreset.DIRECT
private var renderTargetRebindCount = 0
private var lastRenderTargetRebindCostMs: Long? = null
private var progressJob: Job? = null private var progressJob: Job? = null
@@ -79,6 +85,14 @@ class VodPlayActivity : AppCompatActivity() {
private var logSummaryView: TextView? = null private var logSummaryView: TextView? = null
private var logContentView: TextView? = null private var logContentView: TextView? = null
private var logFloatingButton: View? = null private var logFloatingButton: View? = null
private var toolsFloatingButton: View? = null
private val playbackObserverDemo by lazy(LazyThreadSafetyMode.NONE) {
PlaybackTextureObserverDemo(::logEvent)
}
private val playbackPatchProcessor by lazy(LazyThreadSafetyMode.NONE) {
PlaybackTexturePatchProcessor(::logEvent)
}
private val storagePermissionLauncher = registerForActivityResult( private val storagePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
@@ -95,6 +109,7 @@ class VodPlayActivity : AppCompatActivity() {
supportActionBar?.hide() supportActionBar?.hide()
useTextureView = AvDemoSettingsStore(this).read().renderBackendPreference.isTextureView() useTextureView = AvDemoSettingsStore(this).read().renderBackendPreference.isTextureView()
addLogFloatingButton() addLogFloatingButton()
addToolsFloatingButton()
binding.btnClose.setOnClickListener { finish() } binding.btnClose.setOnClickListener { finish() }
binding.actionPlay.setOnClickListener { togglePlay() } binding.actionPlay.setOnClickListener { togglePlay() }
@@ -253,8 +268,9 @@ class VodPlayActivity : AppCompatActivity() {
client.setMuted(isMuted) client.setMuted(isMuted)
} }
val backend = if (useTextureView) RenderBackend.TEXTURE_VIEW else RenderBackend.SURFACE_VIEW val backend = currentRenderBackend()
renderView = vodPlayer.attachRenderView(binding.renderContainer, backend) renderView = vodPlayer.attachRenderView(binding.renderContainer, backend)
logEvent("渲染目标已绑定: backend=${currentRenderBackendLabel()}, processing=${processingPreset.label}")
player = vodPlayer player = vodPlayer
startPlayAttempt() startPlayAttempt()
vodPlayer.prepareAsync() vodPlayer.prepareAsync()
@@ -427,6 +443,98 @@ class VodPlayActivity : AppCompatActivity() {
logFloatingButton = button logFloatingButton = button
} }
private fun addToolsFloatingButton() {
val sizePx = dpToPx(44)
val marginEndPx = dpToPx(72)
val controlBarHeight = resources.getDimensionPixelSize(R.dimen.av_control_bar_height)
val marginBottomPx = controlBarHeight + dpToPx(80)
val bgDrawable = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(
Color.parseColor("#B33B0764"),
Color.parseColor("#803B0764")
)).apply {
shape = GradientDrawable.OVAL
setStroke(dpToPx(1), Color.parseColor("#55FFFFFF"))
}
val button = AppCompatTextView(this).apply {
text = ""
setTextColor(Color.parseColor("#F8FAFC"))
textSize = 11f
gravity = Gravity.CENTER
background = bgDrawable
elevation = dpToPx(4).toFloat()
setShadowLayer(2f, 0f, 1f, Color.parseColor("#66000000"))
isClickable = true
isFocusable = true
contentDescription = "播放处理与回归工具"
setOnClickListener { showPlaybackToolsDialog() }
}
val params = FrameLayout.LayoutParams(sizePx, sizePx).apply {
gravity = Gravity.END or Gravity.BOTTOM
marginEnd = marginEndPx
bottomMargin = marginBottomPx
}
addContentView(button, params)
toolsFloatingButton = button
}
private fun showPlaybackToolsDialog() {
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dpToPx(20), dpToPx(16), dpToPx(20), dpToPx(8))
}
val summary = TextView(this).apply {
text = "当前后端: ${currentRenderBackendLabel()}\n" +
"当前模式: ${processingPreset.label}\n" +
"说明: processing 仅支持 TextureView。"
setTextColor(Color.parseColor("#E5E7EB"))
textSize = 13f
}
container.addView(summary)
container.addView(spaceView(dpToPx(12)))
container.addView(createToolActionButton("切换 DIRECT 直出") {
applyPlaybackProcessingPreset(PlaybackProcessingPreset.DIRECT, trigger = "工具面板")
})
container.addView(createToolActionButton("切换 PROCESSING Observer") {
applyPlaybackProcessingPreset(PlaybackProcessingPreset.OBSERVER, trigger = "工具面板")
})
container.addView(createToolActionButton("切换 PROCESSING 红块 Processor") {
applyPlaybackProcessingPreset(PlaybackProcessingPreset.PROCESSOR, trigger = "工具面板")
})
container.addView(createToolActionButton("仅重绑当前目标") {
rebindRenderTarget("手动回归")
})
AlertDialog.Builder(this)
.setTitle("播放处理 / 目标重绑")
.setView(container)
.setNegativeButton("关闭", null)
.show()
}
private fun createToolActionButton(label: String, onClick: () -> Unit): View {
return AppCompatTextView(this).apply {
text = label
gravity = Gravity.CENTER
textSize = 14f
setTextColor(Color.parseColor("#F8FAFC"))
background = GradientDrawable().apply {
cornerRadius = dpToPx(10).toFloat()
setColor(Color.parseColor("#334155"))
setStroke(dpToPx(1), Color.parseColor("#475569"))
}
setPadding(dpToPx(12), dpToPx(12), dpToPx(12), dpToPx(12))
isClickable = true
isFocusable = true
setOnClickListener { onClick() }
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
bottomMargin = dpToPx(10)
}
}
}
private fun showLogDialog() { private fun showLogDialog() {
if (logDialog?.isShowing == true) { if (logDialog?.isShowing == true) {
refreshLogDialogContent() refreshLogDialogContent()
@@ -552,12 +660,22 @@ class VodPlayActivity : AppCompatActivity() {
private fun buildLogSummary(): String { private fun buildLogSummary(): String {
val builder = StringBuilder() val builder = StringBuilder()
builder.append("状态: ").append(formatState(currentState)).append('\n') builder.append("状态: ").append(formatState(currentState)).append('\n')
builder.append("渲染后端: ").append(currentRenderBackendLabel()).append('\n')
builder.append("播放处理: ").append(processingPreset.label).append('\n')
builder.append("是否播放中: ").append(if (isPlaying) "" else "").append('\n') builder.append("是否播放中: ").append(if (isPlaying) "" else "").append('\n')
builder.append("是否静音: ").append(if (isMuted) "" else "").append('\n') builder.append("是否静音: ").append(if (isMuted) "" else "").append('\n')
builder.append("总时长: ").append(if (durationMs > 0) formatTime(durationMs) else "--").append('\n') builder.append("总时长: ").append(if (durationMs > 0) formatTime(durationMs) else "--").append('\n')
builder.append("当前进度: ").append(formatTime(player?.getCurrentPositionMs() ?: 0L)).append('\n') builder.append("当前进度: ").append(formatTime(player?.getCurrentPositionMs() ?: 0L)).append('\n')
builder.append("首帧视频耗时(ms): ").append(firstVideoFrameCostMs ?: "未统计").append('\n') builder.append("首帧视频耗时(ms): ").append(firstVideoFrameCostMs ?: "未统计").append('\n')
builder.append("首帧音频耗时(ms): ").append(firstAudioFrameCostMs ?: "未统计").append('\n') builder.append("首帧音频耗时(ms): ").append(firstAudioFrameCostMs ?: "未统计").append('\n')
builder.append("目标重绑次数: ").append(renderTargetRebindCount).append('\n')
builder.append("最近重绑耗时(ms): ").append(lastRenderTargetRebindCostMs ?: "未统计").append('\n')
val processingDetail = when (processingPreset) {
PlaybackProcessingPreset.DIRECT -> "processing: 关闭"
PlaybackProcessingPreset.OBSERVER -> playbackObserverDemo.summary()
PlaybackProcessingPreset.PROCESSOR -> playbackPatchProcessor.summary()
}
builder.append(processingDetail).append('\n')
val attemptElapsed = playAttemptStartElapsedMs?.let { SystemClock.elapsedRealtime() - it } val attemptElapsed = playAttemptStartElapsedMs?.let { SystemClock.elapsedRealtime() - it }
if (attemptElapsed == null) { if (attemptElapsed == null) {
builder.append("本次播放已耗时(ms): 未开始").append('\n') builder.append("本次播放已耗时(ms): 未开始").append('\n')
@@ -624,7 +742,65 @@ class VodPlayActivity : AppCompatActivity() {
firstAudioFrameElapsedMs = null firstAudioFrameElapsedMs = null
firstAudioFrameCostMs = null firstAudioFrameCostMs = null
bufferingActive = false bufferingActive = false
logEvent("播放尝试开始") logEvent("播放尝试开始: backend=${currentRenderBackendLabel()}, processing=${processingPreset.label}")
}
private fun applyPlaybackProcessingPreset(preset: PlaybackProcessingPreset, trigger: String) {
if (preset == processingPreset) {
logEvent("播放处理保持不变: ${preset.label}, trigger=$trigger")
Toast.makeText(this, "当前已是 ${preset.label}", Toast.LENGTH_SHORT).show()
return
}
if (!useTextureView && preset != PlaybackProcessingPreset.DIRECT) {
logEvent("播放处理切换被拒绝: backend=${currentRenderBackendLabel()} 不支持 ${preset.label}")
Toast.makeText(this, "播放 processing 仅支持 TextureView 后端", Toast.LENGTH_SHORT).show()
return
}
processingPreset = preset
configurePlaybackProcessing()
logEvent("播放处理切换: mode=${preset.label}, trigger=$trigger")
rebindRenderTarget("processing_${preset.name.lowercase(Locale.US)}")
Toast.makeText(this, "已切到 ${preset.label}", Toast.LENGTH_SHORT).show()
}
private fun configurePlaybackProcessing() {
val currentPlayer = player ?: return
when (processingPreset) {
PlaybackProcessingPreset.DIRECT -> {
currentPlayer.setPlaybackFrameObserver(null)
currentPlayer.setPlaybackVideoProcessor(null)
}
PlaybackProcessingPreset.OBSERVER -> {
currentPlayer.setPlaybackVideoProcessor(null)
currentPlayer.setPlaybackFrameObserver(playbackObserverDemo)
}
PlaybackProcessingPreset.PROCESSOR -> {
currentPlayer.setPlaybackFrameObserver(null)
currentPlayer.setPlaybackVideoProcessor(playbackPatchProcessor)
}
}
}
private fun rebindRenderTarget(reason: String) {
val currentPlayer = player ?: return
val startedAtMs = SystemClock.elapsedRealtime()
val backend = currentRenderBackend()
logEvent("目标重绑开始: reason=$reason, backend=${currentRenderBackendLabel()}, processing=${processingPreset.label}")
currentPlayer.clearRenderTarget()
renderView = currentPlayer.attachRenderView(binding.renderContainer, backend)
val costMs = SystemClock.elapsedRealtime() - startedAtMs
renderTargetRebindCount += 1
lastRenderTargetRebindCostMs = costMs
logEvent("目标重绑完成: count=$renderTargetRebindCount, cost=${costMs}ms")
}
private fun currentRenderBackend(): RenderBackend {
return if (useTextureView) RenderBackend.TEXTURE_VIEW else RenderBackend.SURFACE_VIEW
}
private fun currentRenderBackendLabel(): String {
return if (useTextureView) "TextureView" else "SurfaceView"
} }
private fun formatState(state: SellyPlayerState): String { private fun formatState(state: SellyPlayerState): String {

View File

@@ -130,6 +130,14 @@
android:text="RTMP" android:text="RTMP"
android:textColor="@color/brand_primary_text_on" android:textColor="@color/brand_primary_text_on"
android:textSize="11sp" /> android:textSize="11sp" />
<TextView
android:id="@+id/tvXorBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:textSize="13sp"
android:visibility="gone" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>