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

Binary file not shown.

View File

@@ -171,7 +171,7 @@ class FeatureHubActivity : AppCompatActivity() {
if (dy <= 0) return
val lastVisible = layoutManager.findLastVisibleItemPosition()
if (lastVisible >= aliveAdapter.itemCount - 2) {
appendNextPage()
recyclerView.post { appendNextPage() }
}
}
})
@@ -243,12 +243,14 @@ class FeatureHubActivity : AppCompatActivity() {
}
val url = item.url?.trim().orEmpty()
val xorKey = item.xorKey.orEmpty()
val intent = if (url.isNotEmpty()) {
LivePlayActivity.createIntent(
this,
resolvePlayModeFromUrl(url),
url,
autoStart = true
autoStart = true,
xorKeyHex = xorKey
)
} else {
val liveMode = resolvePlayMode(item.playProtocol)
@@ -263,7 +265,8 @@ class FeatureHubActivity : AppCompatActivity() {
params.vhost,
params.appName,
params.streamName,
autoStart = true
autoStart = true,
xorKeyHex = xorKey
)
}.apply {
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.toLiveMode
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.SellyLiveMode
import com.sellycloud.sellycloudsdk.SellyLiveVideoPlayer
@@ -89,6 +92,9 @@ class LivePlayActivity : AppCompatActivity() {
private var lastLatencyChasingUpdate: SellyLatencyChasingUpdate? = null
private var hasReleasedPlayer: Boolean = false
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 logTimeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
@@ -96,6 +102,14 @@ class LivePlayActivity : AppCompatActivity() {
private var logSummaryView: TextView? = null
private var logContentView: TextView? = 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(
ActivityResultContracts.RequestPermission()
@@ -111,6 +125,7 @@ class LivePlayActivity : AppCompatActivity() {
setContentView(binding.root)
supportActionBar?.hide()
addLogFloatingButton()
addToolsFloatingButton()
envStore = LiveEnvSettingsStore(this)
useTextureView = AvDemoSettingsStore(this).read().renderBackendPreference.isTextureView()
@@ -221,8 +236,9 @@ class LivePlayActivity : AppCompatActivity() {
binding.actionScreenshot.setOnClickListener { captureCurrentFrame() }
binding.actionPip.setOnClickListener { enterPipMode() }
val backend = if (useTextureView) RenderBackend.TEXTURE_VIEW else RenderBackend.SURFACE_VIEW
val backend = currentRenderBackend()
playerClient.attachRenderView(binding.renderContainer, backend)
logEvent("渲染目标已绑定: backend=${currentRenderBackendLabel()}, processing=${processingPreset.label}")
if (args.autoStart) {
lifecycleScope.launch {
@@ -429,6 +445,7 @@ class LivePlayActivity : AppCompatActivity() {
binding.controlBar.visibility = controlsVisibility
binding.btnClose.visibility = controlsVisibility
logFloatingButton?.visibility = controlsVisibility
toolsFloatingButton?.visibility = controlsVisibility
if (isInPip) {
binding.ivPreview.visibility = View.GONE
} else {
@@ -547,6 +564,99 @@ class LivePlayActivity : AppCompatActivity() {
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() {
if (logDialog?.isShowing == true) {
refreshLogDialogContent()
@@ -680,10 +790,20 @@ class LivePlayActivity : AppCompatActivity() {
builder.append("streamName: ").append(params.streamName).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 (isMuted) "" else "").append('\n')
builder.append("首帧视频耗时(ms): ").append(firstVideoFrameCostMs ?: "未统计").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 }
if (attemptElapsed == null) {
builder.append("本次播放已耗时(ms): 未开始").append('\n')
@@ -752,7 +872,81 @@ class LivePlayActivity : AppCompatActivity() {
isLatencyChasingActive = false
lastLatencyChasingSpeed = 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 {
@@ -840,7 +1034,8 @@ class LivePlayActivity : AppCompatActivity() {
vhost: String,
appName: String,
streamName: String,
autoStart: Boolean = true
autoStart: Boolean = true,
xorKeyHex: String = ""
): Intent {
return Intent(context, LivePlayActivity::class.java)
.putExtra(EXTRA_PLAY_PROTOCOL, liveMode.name)
@@ -848,6 +1043,7 @@ class LivePlayActivity : AppCompatActivity() {
.putExtra(EXTRA_PLAY_APP_NAME, appName)
.putExtra(EXTRA_PLAY_STREAM_NAME, streamName)
.putExtra(EXTRA_AUTO_START, autoStart)
.putExtra(EXTRA_XOR_KEY_HEX, xorKeyHex)
}
fun closePipIfAny(): Boolean {
@@ -908,7 +1104,8 @@ class LivePlayActivity : AppCompatActivity() {
val input = intent.getStringExtra(EXTRA_STREAM_ID_OR_URL).orEmpty()
.ifBlank { playParams?.streamName ?: env.defaultStreamId }
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)
return Args(
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 {
val normalized = raw?.trim()?.uppercase()
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.env.LiveEnvSettingsStore
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.square.StreamXorRepository
import com.demo.SellyCloudSDK.live.util.GalleryImageSaver
import com.sellycloud.sellycloudsdk.CpuUsage
import com.sellycloud.sellycloudsdk.Disposable
@@ -85,6 +88,7 @@ class LivePushActivity : AppCompatActivity() {
private var useTextureView: Boolean = false
private var isPublishing: Boolean = false
private var hasReportedXor: Boolean = false
private var isStatsCollapsed: Boolean = false
private var latestStats: SellyLivePusherStats? = null
private var isMuted: Boolean = false
@@ -361,11 +365,109 @@ class LivePushActivity : AppCompatActivity() {
updateLayoutForOrientationAndState()
updateStatusPanel()
updateStatsFromStats(latestStats)
if (state == SellyLiveStatus.Publishing && !hasReportedXor) {
reportXorKeyToServer()
}
if (state == SellyLiveStatus.Stopped) {
if (hasReportedXor) {
clearXorKeyOnServer()
}
hasReportedXor = false
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() {
if (hasNavigatedHome || isFinishing || isDestroyed) return
hasNavigatedHome = true
@@ -613,11 +715,8 @@ class LivePushActivity : AppCompatActivity() {
private fun toggleFrameInterceptor() {
frameInterceptorMode = when (frameInterceptorMode) {
FrameInterceptorMode.OFF -> FrameInterceptorMode.OBSERVE
FrameInterceptorMode.OBSERVE -> FrameInterceptorMode.CPU_EMPTY
FrameInterceptorMode.CPU_EMPTY -> FrameInterceptorMode.CPU_SINGLE
FrameInterceptorMode.CPU_SINGLE -> FrameInterceptorMode.CPU_DOUBLE
FrameInterceptorMode.CPU_DOUBLE -> FrameInterceptorMode.OFF
FrameInterceptorMode.MODIFY -> FrameInterceptorMode.OBSERVE
FrameInterceptorMode.OBSERVE -> FrameInterceptorMode.OFF
else -> FrameInterceptorMode.OBSERVE
}
resetFrameCallbackWindow(if (frameInterceptorMode == FrameInterceptorMode.OFF) "off" else frameInterceptorMode.label)
applyFrameInterceptorState()
@@ -677,46 +776,12 @@ class LivePushActivity : AppCompatActivity() {
})
}
FrameInterceptorMode.CPU_EMPTY -> {
addFrameObserver(activePusher, object : VideoFrameObserver {
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_EMPTY,
FrameInterceptorMode.CPU_SINGLE,
FrameInterceptorMode.CPU_DOUBLE -> {
addFrameObserver(activePusher, object : VideoFrameObserver {
override val config: VideoFrameObserverConfig = VideoFrameObserverConfig(
preferredFormat = VideoProcessFormat.I420
)
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.
}
})
// CPU observer modes removed from default toggle.
// SDK still supports I420/RGBA observers; these were disabled in Demo
// because the current pure-Kotlin color conversion is too slow for production.
}
FrameInterceptorMode.MODIFY -> {
@@ -1389,8 +1454,13 @@ class LivePushActivity : AppCompatActivity() {
}
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(
frame = frame,
frame = modifiedFrame,
bufferKind = bufferKind,
patchLabel = patchLabel,
editNs = editNs

View File

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

View File

@@ -53,6 +53,9 @@ class AliveStreamAdapter(
binding.tvStreamName.text = title
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
?.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.databinding.ActivityVodPlayBinding
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.SellyLiveError
import com.sellycloud.sellycloudsdk.SellyPlayerState
@@ -70,6 +73,9 @@ class VodPlayActivity : AppCompatActivity() {
private var firstAudioFrameElapsedMs: Long? = null
private var firstAudioFrameCostMs: Long? = null
private var bufferingActive = false
private var processingPreset: PlaybackProcessingPreset = PlaybackProcessingPreset.DIRECT
private var renderTargetRebindCount = 0
private var lastRenderTargetRebindCostMs: Long? = null
private var progressJob: Job? = null
@@ -79,6 +85,14 @@ class VodPlayActivity : AppCompatActivity() {
private var logSummaryView: TextView? = null
private var logContentView: TextView? = 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(
ActivityResultContracts.RequestPermission()
@@ -95,6 +109,7 @@ class VodPlayActivity : AppCompatActivity() {
supportActionBar?.hide()
useTextureView = AvDemoSettingsStore(this).read().renderBackendPreference.isTextureView()
addLogFloatingButton()
addToolsFloatingButton()
binding.btnClose.setOnClickListener { finish() }
binding.actionPlay.setOnClickListener { togglePlay() }
@@ -253,8 +268,9 @@ class VodPlayActivity : AppCompatActivity() {
client.setMuted(isMuted)
}
val backend = if (useTextureView) RenderBackend.TEXTURE_VIEW else RenderBackend.SURFACE_VIEW
val backend = currentRenderBackend()
renderView = vodPlayer.attachRenderView(binding.renderContainer, backend)
logEvent("渲染目标已绑定: backend=${currentRenderBackendLabel()}, processing=${processingPreset.label}")
player = vodPlayer
startPlayAttempt()
vodPlayer.prepareAsync()
@@ -427,6 +443,98 @@ class VodPlayActivity : AppCompatActivity() {
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() {
if (logDialog?.isShowing == true) {
refreshLogDialogContent()
@@ -552,12 +660,22 @@ class VodPlayActivity : AppCompatActivity() {
private fun buildLogSummary(): String {
val builder = StringBuilder()
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 (isMuted) "" 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("首帧视频耗时(ms): ").append(firstVideoFrameCostMs ?: "未统计").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 }
if (attemptElapsed == null) {
builder.append("本次播放已耗时(ms): 未开始").append('\n')
@@ -624,7 +742,65 @@ class VodPlayActivity : AppCompatActivity() {
firstAudioFrameElapsedMs = null
firstAudioFrameCostMs = null
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 {

View File

@@ -130,6 +130,14 @@
android:text="RTMP"
android:textColor="@color/brand_primary_text_on"
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>
</androidx.constraintlayout.widget.ConstraintLayout>