From d0b16788333e0c8c2eae2ab47a5791386927c1ca Mon Sep 17 00:00:00 2001 From: shou Date: Mon, 23 Feb 2026 09:46:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0PK=E6=92=AD=E6=94=BE=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3UI?= =?UTF-8?q?=E5=92=8C=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=BB?= =?UTF-8?q?=E6=B5=81=E4=B8=8EPK=E6=B5=81=E7=9A=84=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/src/main/AndroidManifest.xml | 8 + .../demo/SellyCloudSDK/FeatureHubActivity.kt | 30 + .../demo/SellyCloudSDK/live/PkPlayActivity.kt | 733 ++++++++++++++++++ .../live/square/AliveListRepository.kt | 12 +- .../live/square/AliveStreamAdapter.kt | 2 + .../main/res/drawable/bg_live_pk_badge.xml | 5 + .../src/main/res/layout/activity_pk_play.xml | 159 ++++ .../main/res/layout/item_live_square_card.xml | 17 + 8 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/PkPlayActivity.kt create mode 100644 example/src/main/res/drawable/bg_live_pk_badge.xml create mode 100644 example/src/main/res/layout/activity_pk_play.xml 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" /> + +