diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml
index dde2f3d..e4b9530 100644
--- a/example/src/main/AndroidManifest.xml
+++ b/example/src/main/AndroidManifest.xml
@@ -64,6 +64,14 @@
android:supportsPictureInPicture="true"
android:parentActivityName=".FeatureHubActivity" />
+
+
= ArrayDeque()
+ private val logTimeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
+ private var logDialog: AlertDialog? = null
+ private var logSummaryView: TextView? = null
+ private var logContentView: TextView? = null
+ private var logFloatingButton: View? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityPkPlayBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ supportActionBar?.hide()
+ addLogFloatingButton()
+
+ envStore = LiveEnvSettingsStore(this)
+ val env = envStore.read().also { it.applyToSdkRuntimeConfig(this) }
+ args = Args.from(intent, env) ?: run {
+ Toast.makeText(this, "缺少 PK 播放参数", Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
+
+ binding.tvMainStreamName.text = args.mainStreamName
+ binding.tvPkStreamName.text = args.pkStreamName
+
+ Log.d(TAG, "初始化主播放器:streamId=${args.mainStreamName}, 协议: RTC")
+ mainPlayer = SellyLiveVideoPlayer.initWithStreamId(
+ this,
+ args.mainStreamName,
+ liveMode = SellyLiveMode.RTC,
+ vhost = args.vhost,
+ appName = args.appName
+ )
+ mainPlayer.delegate = createPlayerDelegate(
+ prefix = "主播放器",
+ onStateChanged = { state ->
+ mainCurrentState = state
+ mainIsPlaying = state == SellyPlayerState.Playing
+ updatePlayingUi()
+ },
+ onFirstVideo = { startMs ->
+ val cost = SystemClock.elapsedRealtime() - startMs
+ mainFirstVideoFrameCostMs = cost
+ logEvent("主播放器: 首帧视频耗时=${cost}ms")
+ },
+ onFirstAudio = { startMs ->
+ val cost = SystemClock.elapsedRealtime() - startMs
+ mainFirstAudioFrameCostMs = cost
+ logEvent("主播放器: 首帧音频耗时=${cost}ms")
+ },
+ getPlayAttemptStartMs = { mainPlayAttemptStartMs },
+ getIsLatencyChasing = { mainIsLatencyChasingActive },
+ setIsLatencyChasing = { mainIsLatencyChasingActive = it },
+ getLastChasingSpeed = { mainLastLatencyChasingSpeed },
+ setLastChasingSpeed = { mainLastLatencyChasingSpeed = it },
+ setLastChasingUpdate = { mainLastLatencyChasingUpdate = it },
+ getLastChasingUpdate = { mainLastLatencyChasingUpdate }
+ )
+ mainPlayer.setMuted(isMuted)
+
+ Log.d(TAG, "初始化 PK 播放器:streamId=${args.pkStreamName}")
+ pkPlayer = SellyLiveVideoPlayer.initWithStreamId(
+ this,
+ args.pkStreamName,
+ liveMode = SellyLiveMode.RTC,
+ vhost = args.vhost,
+ appName = args.appName
+ )
+ pkPlayer.delegate = createPlayerDelegate(
+ prefix = "PK播放器",
+ onStateChanged = { state ->
+ pkCurrentState = state
+ pkIsPlaying = state == SellyPlayerState.Playing
+ updatePlayingUi()
+ },
+ onFirstVideo = { startMs ->
+ val cost = SystemClock.elapsedRealtime() - startMs
+ pkFirstVideoFrameCostMs = cost
+ logEvent("PK播放器: 首帧视频耗时=${cost}ms")
+ },
+ onFirstAudio = { startMs ->
+ val cost = SystemClock.elapsedRealtime() - startMs
+ pkFirstAudioFrameCostMs = cost
+ logEvent("PK播放器: 首帧音频耗时=${cost}ms")
+ },
+ getPlayAttemptStartMs = { pkPlayAttemptStartMs },
+ getIsLatencyChasing = { pkIsLatencyChasingActive },
+ setIsLatencyChasing = { pkIsLatencyChasingActive = it },
+ getLastChasingSpeed = { pkLastLatencyChasingSpeed },
+ setLastChasingSpeed = { pkLastLatencyChasingSpeed = it },
+ setLastChasingUpdate = { pkLastLatencyChasingUpdate = it },
+ getLastChasingUpdate = { pkLastLatencyChasingUpdate }
+ )
+ pkPlayer.setMuted(isMuted)
+
+ mainPlayer.attachRenderView(binding.mainRenderContainer)
+ pkPlayer.attachRenderView(binding.pkRenderContainer)
+
+ binding.btnClose.setOnClickListener { finish() }
+ binding.actionPlay.setOnClickListener { togglePlay() }
+ binding.actionMute.setOnClickListener { toggleMute() }
+
+ if (args.autoStart) {
+ startPlayback()
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ logEvent("释放播放器")
+ logDialog?.dismiss()
+ releasePlayersIfNeeded()
+ uiScope.cancel()
+ }
+
+ private fun createPlayerDelegate(
+ prefix: String,
+ onStateChanged: (SellyPlayerState) -> Unit,
+ onFirstVideo: (Long) -> Unit,
+ onFirstAudio: (Long) -> Unit,
+ getPlayAttemptStartMs: () -> Long?,
+ getIsLatencyChasing: () -> Boolean,
+ setIsLatencyChasing: (Boolean) -> Unit,
+ getLastChasingSpeed: () -> Float?,
+ setLastChasingSpeed: (Float?) -> Unit,
+ setLastChasingUpdate: (SellyLatencyChasingUpdate?) -> Unit,
+ getLastChasingUpdate: () -> SellyLatencyChasingUpdate?
+ ): SellyLiveVideoPlayerDelegate {
+ var hasFirstVideo = false
+ var hasFirstAudio = false
+
+ return object : SellyLiveVideoPlayerDelegate {
+ override fun playbackStateChanged(state: SellyPlayerState) {
+ runOnUiThread {
+ onStateChanged(state)
+ logEvent("$prefix: 状态变更: ${formatState(state)}")
+ }
+ }
+
+ override fun onFirstVideoFrameRendered() {
+ runOnUiThread {
+ if (hasFirstVideo) return@runOnUiThread
+ hasFirstVideo = true
+ val startMs = getPlayAttemptStartMs() ?: return@runOnUiThread
+ onFirstVideo(startMs)
+ }
+ }
+
+ override fun onFirstAudioFrameRendered() {
+ runOnUiThread {
+ if (hasFirstAudio) return@runOnUiThread
+ hasFirstAudio = true
+ val startMs = getPlayAttemptStartMs() ?: return@runOnUiThread
+ onFirstAudio(startMs)
+ }
+ }
+
+ override fun onLatencyChasingUpdate(update: SellyLatencyChasingUpdate) {
+ runOnUiThread {
+ val speedRounded = kotlin.math.round(update.speed * 10f) / 10f
+ val speedText = String.format(Locale.US, "%.1f", speedRounded)
+ val chasingDetail = buildLatencyChasingDetail(update)
+ setLastChasingUpdate(update)
+ val isChasing = speedRounded > 1.0f
+ val wasChasing = getIsLatencyChasing()
+ if (isChasing && !wasChasing) {
+ setIsLatencyChasing(true)
+ logEvent("$prefix: 追帧开始: 速度=${speedText}x, $chasingDetail")
+ setLastChasingSpeed(speedRounded)
+ } else if (isChasing && wasChasing) {
+ if (getLastChasingSpeed() != speedRounded) {
+ logEvent("$prefix: 追帧速率变化: 速度=${speedText}x, $chasingDetail")
+ setLastChasingSpeed(speedRounded)
+ }
+ } else if (!isChasing && wasChasing) {
+ setIsLatencyChasing(false)
+ logEvent("$prefix: 追帧结束: 速度=1.0x, $chasingDetail")
+ setLastChasingSpeed(null)
+ } else {
+ logEvent("$prefix: 追帧状态更新: 速度=${speedText}x, $chasingDetail")
+ }
+ }
+ }
+
+ override fun onLatencyChasingReloadRequired(latencyMs: Long) {
+ runOnUiThread {
+ val lastUpdateDetail = getLastChasingUpdate()
+ ?.let { ", 最近追帧: ${buildLatencyChasingDetail(it)}" }
+ .orEmpty()
+ logEvent("$prefix: 追帧触发重载: 延迟=${latencyMs}ms$lastUpdateDetail")
+ }
+ }
+
+ override fun onError(error: com.sellycloud.sellycloudsdk.SellyLiveError) {
+ runOnUiThread {
+ logEvent("$prefix: 错误: ${error.message}")
+ Toast.makeText(this@PkPlayActivity, "$prefix: ${error.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ private fun startPlayback() {
+ val env = envStore.read()
+
+ // Auth for main stream
+ val mainChannelId = args.mainStreamName
+ val mainAuthError = LiveAuthHelper.validateAuthConfig(env, mainChannelId)
+ if (mainAuthError != null) {
+ Toast.makeText(this, "主播放器鉴权失败: $mainAuthError", Toast.LENGTH_SHORT).show()
+ return
+ }
+ val mainAuth = LiveAuthHelper.buildAuthParams(
+ env = env,
+ channelId = mainChannelId,
+ type = LiveTokenSigner.TokenType.PULL
+ )
+ if (mainAuth == null) {
+ Toast.makeText(this, "主播放器生成 token 失败", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ // Auth for PK stream
+ val pkChannelId = args.pkStreamName
+ val pkAuthError = LiveAuthHelper.validateAuthConfig(env, pkChannelId)
+ if (pkAuthError != null) {
+ Toast.makeText(this, "PK播放器鉴权失败: $pkAuthError", Toast.LENGTH_SHORT).show()
+ return
+ }
+ val pkAuth = LiveAuthHelper.buildAuthParams(
+ env = env,
+ channelId = pkChannelId,
+ type = LiveTokenSigner.TokenType.PULL
+ )
+ if (pkAuth == null) {
+ Toast.makeText(this, "PK播放器生成 token 失败", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ mainPlayer.token = mainAuth.tokenResult.token
+ pkPlayer.token = pkAuth.tokenResult.token
+
+ logEvent("主播放器: 开始播放 streamId=$mainChannelId")
+ logEvent("PK播放器: 开始播放 streamId=$pkChannelId")
+
+ LivePlayForegroundService.start(this)
+ binding.root.post {
+ if (hasReleasedPlayers || isDestroyed) return@post
+ startPlayAttempt()
+ mainPlayer.prepareToPlay()
+ mainPlayer.play()
+ pkPlayer.prepareToPlay()
+ pkPlayer.play()
+ }
+ }
+
+ private fun startPlayAttempt() {
+ val now = SystemClock.elapsedRealtime()
+ mainPlayAttemptStartMs = now
+ mainFirstVideoFrameCostMs = null
+ mainFirstAudioFrameCostMs = null
+ mainIsLatencyChasingActive = false
+ mainLastLatencyChasingSpeed = null
+ mainLastLatencyChasingUpdate = null
+
+ pkPlayAttemptStartMs = now
+ pkFirstVideoFrameCostMs = null
+ pkFirstAudioFrameCostMs = null
+ pkIsLatencyChasingActive = false
+ pkLastLatencyChasingSpeed = null
+ pkLastLatencyChasingUpdate = null
+
+ logEvent("播放尝试开始")
+ }
+
+ private fun togglePlay() {
+ val anyPlaying = mainIsPlaying || pkIsPlaying
+ if (anyPlaying) {
+ logEvent("用户操作: 暂停")
+ if (mainIsPlaying) mainPlayer.pause()
+ if (pkIsPlaying) pkPlayer.pause()
+ LivePlayForegroundService.stop(this)
+ } else {
+ logEvent("用户操作: 播放")
+ val mainPaused = mainCurrentState == SellyPlayerState.Paused
+ val pkPaused = pkCurrentState == SellyPlayerState.Paused
+ if (mainPaused || pkPaused) {
+ if (mainPaused) mainPlayer.play()
+ if (pkPaused) pkPlayer.play()
+ } else {
+ startPlayback()
+ }
+ }
+ }
+
+ private fun toggleMute() {
+ isMuted = !isMuted
+ mainPlayer.setMuted(isMuted)
+ pkPlayer.setMuted(isMuted)
+ binding.tvMuteLabel.setText(if (isMuted) R.string.play_ctrl_unmute else R.string.play_ctrl_mute)
+ logEvent(if (isMuted) "用户操作: 静音" else "用户操作: 取消静音")
+ }
+
+ private fun updatePlayingUi() {
+ val anyPlaying = mainIsPlaying || pkIsPlaying
+ binding.tvPlayLabel.setText(if (anyPlaying) R.string.play_ctrl_pause else R.string.play_ctrl_play)
+ }
+
+ private fun releasePlayersIfNeeded() {
+ if (hasReleasedPlayers) return
+ hasReleasedPlayers = true
+ if (this::mainPlayer.isInitialized) mainPlayer.release()
+ if (this::pkPlayer.isInitialized) pkPlayer.release()
+ LivePlayForegroundService.stop(this)
+ }
+
+ // --- Log system (adapted from LivePlayActivity) ---
+
+ private fun buildLatencyChasingDetail(update: SellyLatencyChasingUpdate): String {
+ val cacheDeltaMs = update.audioCachedMs - update.videoCachedMs
+ val deltaText = if (cacheDeltaMs > 0L) "+$cacheDeltaMs" else cacheDeltaMs.toString()
+ val cacheSkewTag = when {
+ cacheDeltaMs >= CACHE_SKEW_WARNING_MS -> "音频缓存领先"
+ cacheDeltaMs <= -CACHE_SKEW_WARNING_MS -> "视频缓存领先"
+ else -> "音视频缓存接近"
+ }
+ return "延迟=${update.latencyMs}ms, 档位=${update.tier}, 缓冲中=${if (update.buffering) "是" else "否"}, " +
+ "首帧已出=${if (update.firstFrameRendered) "是" else "否"}, 音频缓存=${update.audioCachedMs}ms, " +
+ "视频缓存=${update.videoCachedMs}ms, 缓存差(音-视)=${deltaText}ms($cacheSkewTag)"
+ }
+
+ private fun formatState(state: SellyPlayerState): String {
+ val label = when (state) {
+ SellyPlayerState.Connecting -> "连接中"
+ SellyPlayerState.Reconnecting -> "重连中"
+ SellyPlayerState.Playing -> "播放中"
+ SellyPlayerState.Paused -> "已暂停"
+ SellyPlayerState.StoppedOrEnded -> "已停止"
+ SellyPlayerState.Failed -> "失败"
+ SellyPlayerState.Idle -> "空闲"
+ }
+ return "$label(${state.name})"
+ }
+
+ private fun logEvent(message: String) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ appendLogLine(message)
+ } else {
+ runOnUiThread { appendLogLine(message) }
+ }
+ }
+
+ private fun appendLogLine(message: String) {
+ val timestamp = logTimeFormat.format(Date())
+ if (logLines.size >= MAX_LOG_LINES) {
+ logLines.removeFirst()
+ }
+ logLines.addLast("$timestamp $message")
+ refreshLogDialogContent()
+ }
+
+ private fun buildLogSummary(): String {
+ val builder = StringBuilder()
+ builder.append("主播放器: ").append(args.mainStreamName).append(" (RTC)")
+ builder.append(" 状态: ").append(formatState(mainCurrentState))
+ builder.append(" 首帧: ").append(mainFirstVideoFrameCostMs?.let { "${it}ms" } ?: "未统计")
+ builder.append('\n')
+ builder.append("PK播放器: ").append(args.pkStreamName).append(" (RTC)")
+ builder.append(" 状态: ").append(formatState(pkCurrentState))
+ builder.append(" 首帧: ").append(pkFirstVideoFrameCostMs?.let { "${it}ms" } ?: "未统计")
+ builder.append('\n')
+ builder.append("是否静音: ").append(if (isMuted) "是" else "否")
+ builder.append('\n')
+ builder.append("日志行数: ").append(logLines.size)
+ return builder.toString()
+ }
+
+ private fun buildLogContent(): String {
+ if (logLines.isEmpty()) return "暂无日志"
+ return logLines.joinToString(separator = "\n")
+ }
+
+ private fun refreshLogDialogContent() {
+ val dialog = logDialog ?: return
+ if (!dialog.isShowing) return
+ logSummaryView?.text = buildLogSummary()
+ logContentView?.text = buildLogContent()
+ }
+
+ private fun addLogFloatingButton() {
+ val sizePx = dpToPx(44)
+ val marginEndPx = dpToPx(18)
+ 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("#B31F2937"),
+ Color.parseColor("#80242E3A")
+ )).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 { showLogDialog() }
+ setOnLongClickListener {
+ clearLogs(true)
+ true
+ }
+ }
+ val params = FrameLayout.LayoutParams(sizePx, sizePx).apply {
+ gravity = Gravity.END or Gravity.BOTTOM
+ marginEnd = marginEndPx
+ bottomMargin = marginBottomPx
+ }
+ addContentView(button, params)
+ logFloatingButton = button
+ }
+
+ private fun showLogDialog() {
+ if (logDialog?.isShowing == true) {
+ refreshLogDialogContent()
+ return
+ }
+ val dialogBackground = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(
+ Color.parseColor("#CC0F172A"),
+ Color.parseColor("#A60F172A")
+ )).apply {
+ cornerRadius = dpToPx(16).toFloat()
+ setStroke(dpToPx(1), Color.parseColor("#33FFFFFF"))
+ }
+ val container = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dpToPx(12), dpToPx(10), dpToPx(12), dpToPx(12))
+ }
+ val dialogTitleView = TextView(this).apply {
+ text = "PK 播放日志"
+ setTextColor(Color.parseColor("#F8FAFC"))
+ textSize = 14f
+ setTypeface(Typeface.DEFAULT_BOLD)
+ }
+ val summaryTitle = TextView(this).apply {
+ text = "摘要"
+ setTextColor(Color.parseColor("#F8FAFC"))
+ textSize = 12f
+ setTypeface(Typeface.DEFAULT_BOLD)
+ }
+ val summaryView = TextView(this).apply {
+ text = buildLogSummary()
+ setTextColor(Color.parseColor("#E5E7EB"))
+ textSize = 11f
+ setLineSpacing(dpToPx(1).toFloat(), 1.05f)
+ }
+ val summaryContainer = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ background = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(
+ Color.parseColor("#33FFFFFF"),
+ Color.parseColor("#1AFFFFFF")
+ )).apply {
+ cornerRadius = dpToPx(10).toFloat()
+ setStroke(dpToPx(1), Color.parseColor("#33FFFFFF"))
+ }
+ setPadding(dpToPx(10), dpToPx(8), dpToPx(10), dpToPx(8))
+ }
+ summaryContainer.addView(summaryView)
+ val logTitle = TextView(this).apply {
+ text = "事件日志"
+ setTextColor(Color.parseColor("#F8FAFC"))
+ textSize = 12f
+ setTypeface(Typeface.DEFAULT_BOLD)
+ }
+ val logView = TextView(this).apply {
+ text = buildLogContent()
+ typeface = Typeface.MONOSPACE
+ setTextColor(Color.parseColor("#E2E8F0"))
+ textSize = 11f
+ setLineSpacing(dpToPx(1).toFloat(), 1.05f)
+ setTextIsSelectable(true)
+ }
+ val scrollView = ScrollView(this).apply {
+ isFillViewport = true
+ addView(
+ logView,
+ ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+ )
+ }
+ val logContainer = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ background = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(
+ Color.parseColor("#26FFFFFF"),
+ Color.parseColor("#14FFFFFF")
+ )).apply {
+ cornerRadius = dpToPx(10).toFloat()
+ setStroke(dpToPx(1), Color.parseColor("#33FFFFFF"))
+ }
+ setPadding(dpToPx(8), dpToPx(6), dpToPx(8), dpToPx(6))
+ }
+ val logHeight = (resources.displayMetrics.heightPixels * 0.35f).toInt()
+ logContainer.addView(
+ scrollView,
+ LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, logHeight)
+ )
+ container.addView(dialogTitleView)
+ container.addView(spaceView(dpToPx(6)))
+ container.addView(summaryTitle)
+ container.addView(summaryContainer)
+ container.addView(spaceView(dpToPx(8)))
+ container.addView(logTitle)
+ container.addView(logContainer)
+ val dialog = AlertDialog.Builder(this)
+ .setView(container)
+ .setPositiveButton("复制", null)
+ .setNeutralButton("清空", null)
+ .setNegativeButton("关闭", null)
+ .create()
+ dialog.setOnShowListener {
+ dialog.window?.setBackgroundDrawable(dialogBackground)
+ dialog.window?.setLayout(
+ (resources.displayMetrics.widthPixels * 0.88f).toInt(),
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
+ copyLogsToClipboard()
+ }
+ dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener {
+ clearLogs(false)
+ }
+ dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
+ dialog.dismiss()
+ }
+ }
+ dialog.setOnDismissListener {
+ logDialog = null
+ logSummaryView = null
+ logContentView = null
+ }
+ logDialog = dialog
+ logSummaryView = summaryView
+ logContentView = logView
+ dialog.show()
+ }
+
+ private fun copyLogsToClipboard() {
+ val content = buildString {
+ append("PK 播放日志\n\n摘要\n")
+ append(buildLogSummary())
+ append("\n\n事件日志\n")
+ append(buildLogContent())
+ }
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("pk_playback_logs", content))
+ Toast.makeText(this, "已复制日志", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun clearLogs(showToast: Boolean) {
+ logLines.clear()
+ refreshLogDialogContent()
+ if (showToast) {
+ Toast.makeText(this, "日志已清空", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun dpToPx(dp: Int): Int {
+ return (dp * resources.displayMetrics.density + 0.5f).toInt()
+ }
+
+ private fun spaceView(heightPx: Int): View {
+ return View(this).apply {
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ heightPx
+ )
+ }
+ }
+
+ private data class Args(
+ val mainStreamName: String,
+ val pkStreamName: String,
+ val vhost: String,
+ val appName: String,
+ val previewImageUrl: String?,
+ val autoStart: Boolean
+ ) {
+ companion object {
+ fun from(intent: Intent, env: LiveEnvSettings): Args? {
+ val mainStream = intent.getStringExtra(EXTRA_MAIN_STREAM_NAME)?.trim().orEmpty()
+ val pkStream = intent.getStringExtra(EXTRA_PK_STREAM_NAME)?.trim().orEmpty()
+ if (mainStream.isBlank() || pkStream.isBlank()) return null
+ val vhost = intent.getStringExtra(EXTRA_VHOST)?.trim().orEmpty()
+ .ifBlank { env.normalizedVhost() }
+ val appName = intent.getStringExtra(EXTRA_APP_NAME)?.trim().orEmpty()
+ .ifBlank { env.normalizedAppName() }
+ val previewImageUrl = intent.getStringExtra(EXTRA_PREVIEW_IMAGE_URL)
+ ?.trim()?.takeIf { it.isNotEmpty() }
+ val autoStart = intent.getBooleanExtra(EXTRA_AUTO_START, true)
+ return Args(
+ mainStreamName = mainStream,
+ pkStreamName = pkStream,
+ vhost = vhost,
+ appName = appName,
+ previewImageUrl = previewImageUrl,
+ autoStart = autoStart
+ )
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "PkPlayActivity"
+ private const val MAX_LOG_LINES = 200
+ private const val CACHE_SKEW_WARNING_MS = 300L
+
+ const val EXTRA_MAIN_STREAM_NAME = "main_stream_name"
+ const val EXTRA_PK_STREAM_NAME = "pk_stream_name"
+ const val EXTRA_VHOST = "vhost"
+ const val EXTRA_APP_NAME = "app_name"
+ const val EXTRA_PREVIEW_IMAGE_URL = "preview_image_url"
+ const val EXTRA_AUTO_START = "auto_start"
+
+ fun createIntent(
+ context: Context,
+ mainStreamName: String,
+ pkStreamName: String,
+ vhost: String,
+ appName: String,
+ previewImageUrl: String? = null,
+ autoStart: Boolean = true
+ ): Intent {
+ return Intent(context, PkPlayActivity::class.java)
+ .putExtra(EXTRA_MAIN_STREAM_NAME, mainStreamName)
+ .putExtra(EXTRA_PK_STREAM_NAME, pkStreamName)
+ .putExtra(EXTRA_VHOST, vhost)
+ .putExtra(EXTRA_APP_NAME, appName)
+ .putExtra(EXTRA_AUTO_START, autoStart)
+ .apply {
+ if (previewImageUrl != null) {
+ putExtra(EXTRA_PREVIEW_IMAGE_URL, previewImageUrl)
+ }
+ }
+ }
+ }
+}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveListRepository.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveListRepository.kt
index 8db4754..49f25d5 100644
--- a/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveListRepository.kt
+++ b/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveListRepository.kt
@@ -27,9 +27,13 @@ data class AliveStreamItem(
val url: String?,
val previewImage: String?,
val durationSeconds: Long?,
- val playProtocol: String?
+ val playProtocol: String?,
+ val streamPk: String?
)
+val AliveStreamItem.isPkStream: Boolean
+ get() = !streamPk.isNullOrBlank()
+
object AliveListRepository {
private val client = OkHttpClient()
@@ -94,6 +98,9 @@ private fun JSONObject.toAliveItem(): AliveStreamItem {
.ifBlank { optString("protocol") }
.ifBlank { optString("playProtocol") }
.takeIf { it.isNotBlank() }
+ val streamPk = optString("stream_pk")
+ .ifBlank { optString("streamPk") }
+ .takeIf { it.isNotBlank() }
return AliveStreamItem(
vhost = vhost,
@@ -102,6 +109,7 @@ private fun JSONObject.toAliveItem(): AliveStreamItem {
url = url,
previewImage = previewImage,
durationSeconds = durationSeconds,
- playProtocol = playProtocol
+ playProtocol = playProtocol,
+ streamPk = streamPk
)
}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveStreamAdapter.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveStreamAdapter.kt
index 4b7778f..e1c591f 100644
--- a/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveStreamAdapter.kt
+++ b/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveStreamAdapter.kt
@@ -52,6 +52,8 @@ class AliveStreamAdapter(
val title = item.stream ?: "-"
binding.tvStreamName.text = title
+ binding.tvPkBadge.visibility = if (item.isPkStream) View.VISIBLE else View.GONE
+
val protocol = item.playProtocol
?.trim()
?.uppercase(Locale.getDefault())
diff --git a/example/src/main/res/drawable/bg_live_pk_badge.xml b/example/src/main/res/drawable/bg_live_pk_badge.xml
new file mode 100644
index 0000000..13d07dd
--- /dev/null
+++ b/example/src/main/res/drawable/bg_live_pk_badge.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/example/src/main/res/layout/activity_pk_play.xml b/example/src/main/res/layout/activity_pk_play.xml
new file mode 100644
index 0000000..b73dd5c
--- /dev/null
+++ b/example/src/main/res/layout/activity_pk_play.xml
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/src/main/res/layout/item_live_square_card.xml b/example/src/main/res/layout/item_live_square_card.xml
index f720ff4..e525bcc 100644
--- a/example/src/main/res/layout/item_live_square_card.xml
+++ b/example/src/main/res/layout/item_live_square_card.xml
@@ -40,6 +40,23 @@
android:src="@drawable/ic_av_play"
app:tint="@color/brand_primary" />
+
+