添加PK播放功能,更新相关UI和逻辑,支持主流与PK流的播放

This commit is contained in:
2026-02-23 09:46:16 +08:00
parent 1d3fdac957
commit d0b1678833
8 changed files with 964 additions and 2 deletions

View File

@@ -64,6 +64,14 @@
android:supportsPictureInPicture="true"
android:parentActivityName=".FeatureHubActivity" />
<activity
android:name=".live.PkPlayActivity"
android:exported="false"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden"
android:screenOrientation="fullSensor"
android:theme="@style/Theme.AVDemo.NoActionBar"
android:parentActivityName=".FeatureHubActivity" />
<activity
android:name=".vod.VodPlayActivity"
android:exported="false"

View File

@@ -21,6 +21,7 @@ import com.demo.SellyCloudSDK.databinding.DialogPushProtocolSheetBinding
import com.demo.SellyCloudSDK.interactive.InteractiveLiveActivity
import com.demo.SellyCloudSDK.live.LivePlayActivity
import com.demo.SellyCloudSDK.live.LivePushActivity
import com.demo.SellyCloudSDK.live.PkPlayActivity
import com.demo.SellyCloudSDK.live.env.LiveEnvSettings
import com.demo.SellyCloudSDK.live.env.LiveEnvSettingsStore
import com.demo.SellyCloudSDK.live.env.normalizedAppName
@@ -29,6 +30,7 @@ import com.demo.SellyCloudSDK.live.square.AliveListRepository
import com.demo.SellyCloudSDK.live.square.AliveListResult
import com.demo.SellyCloudSDK.live.square.AliveStreamAdapter
import com.demo.SellyCloudSDK.live.square.AliveStreamItem
import com.demo.SellyCloudSDK.live.square.isPkStream
import com.demo.SellyCloudSDK.login.DemoLoginStore
import com.demo.SellyCloudSDK.login.LoginActivity
import com.demo.SellyCloudSDK.vod.VodPlayActivity
@@ -207,6 +209,12 @@ class FeatureHubActivity : AppCompatActivity() {
private fun handleAliveItemClick(item: AliveStreamItem) {
LivePlayActivity.closePipIfAny()
if (item.isPkStream) {
handlePkItemClick(item)
return
}
val url = item.url?.trim().orEmpty()
val intent = if (url.isNotEmpty()) {
LivePlayActivity.createIntent(
@@ -236,6 +244,28 @@ class FeatureHubActivity : AppCompatActivity() {
startActivity(intent)
}
private fun handlePkItemClick(item: AliveStreamItem) {
val mainStream = item.stream?.trim().orEmpty()
val pkStream = item.streamPk?.trim().orEmpty()
if (mainStream.isBlank() || pkStream.isBlank()) {
Toast.makeText(this, "PK 播放参数缺失", Toast.LENGTH_SHORT).show()
return
}
val env = envStore.read()
val vhost = item.vhost?.trim().orEmpty().ifBlank { env.normalizedVhost() }
val appName = item.app?.trim().orEmpty().ifBlank { env.normalizedAppName() }
val intent = PkPlayActivity.createIntent(
context = this,
mainStreamName = mainStream,
pkStreamName = pkStream,
vhost = vhost,
appName = appName,
previewImageUrl = item.previewImage,
autoStart = true
)
startActivity(intent)
}
private fun resolvePlayMode(raw: String?): SellyLiveMode {
val normalized = raw?.trim()?.uppercase() ?: ""
return if (normalized == "WHEP" || normalized == "WHIP" || normalized == "RTC") {

View File

@@ -0,0 +1,733 @@
package com.demo.SellyCloudSDK.live
import android.content.Context
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.graphics.Typeface
import android.os.Bundle
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatTextView
import com.demo.SellyCloudSDK.R
import com.demo.SellyCloudSDK.databinding.ActivityPkPlayBinding
import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper
import com.demo.SellyCloudSDK.live.auth.LiveTokenSigner
import com.demo.SellyCloudSDK.live.env.LiveEnvSettings
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.sellycloud.sellycloudsdk.SellyLatencyChasingUpdate
import com.sellycloud.sellycloudsdk.SellyLiveMode
import com.sellycloud.sellycloudsdk.SellyLiveVideoPlayer
import com.sellycloud.sellycloudsdk.SellyLiveVideoPlayerDelegate
import com.sellycloud.sellycloudsdk.SellyPlayerState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class PkPlayActivity : AppCompatActivity() {
private lateinit var binding: ActivityPkPlayBinding
private lateinit var envStore: LiveEnvSettingsStore
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var args: Args
private lateinit var mainPlayer: SellyLiveVideoPlayer
private lateinit var pkPlayer: SellyLiveVideoPlayer
// Main player state
private var mainIsPlaying: Boolean = false
private var mainCurrentState: SellyPlayerState = SellyPlayerState.Idle
private var mainPlayAttemptStartMs: Long? = null
private var mainFirstVideoFrameCostMs: Long? = null
private var mainFirstAudioFrameCostMs: Long? = null
private var mainIsLatencyChasingActive: Boolean = false
private var mainLastLatencyChasingSpeed: Float? = null
private var mainLastLatencyChasingUpdate: SellyLatencyChasingUpdate? = null
// PK player state
private var pkIsPlaying: Boolean = false
private var pkCurrentState: SellyPlayerState = SellyPlayerState.Idle
private var pkPlayAttemptStartMs: Long? = null
private var pkFirstVideoFrameCostMs: Long? = null
private var pkFirstAudioFrameCostMs: Long? = null
private var pkIsLatencyChasingActive: Boolean = false
private var pkLastLatencyChasingSpeed: Float? = null
private var pkLastLatencyChasingUpdate: SellyLatencyChasingUpdate? = null
// Shared state
private var isMuted: Boolean = false
private var hasReleasedPlayers: Boolean = false
// Log system
private val logLines: ArrayDeque<String> = 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)
}
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E53935" />
<corners android:radius="6dp" />
</shape>

View File

@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/av_surface_black">
<FrameLayout
android:id="@+id/mainRenderContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/controlBar"
app:layout_constraintEnd_toStartOf="@id/pkRenderContainer"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintHorizontal_weight="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<FrameLayout
android:id="@+id/pkRenderContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/controlBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="1"
app:layout_constraintStart_toEndOf="@id/mainRenderContainer"
app:layout_constraintTop_toTopOf="parent" />
<View
android:layout_width="1dp"
android:layout_height="0dp"
android:background="#33FFFFFF"
app:layout_constraintBottom_toTopOf="@id/controlBar"
app:layout_constraintEnd_toStartOf="@id/pkRenderContainer"
app:layout_constraintStart_toEndOf="@id/mainRenderContainer"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvMainStreamName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_live_duration_badge"
android:paddingStart="10dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="4dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="@id/mainRenderContainer"
app:layout_constraintTop_toTopOf="@id/mainRenderContainer" />
<TextView
android:id="@+id/tvPkStreamName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_live_duration_badge"
android:paddingStart="10dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="4dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="@id/pkRenderContainer"
app:layout_constraintTop_toTopOf="@id/pkRenderContainer" />
<ImageButton
android:id="@+id/btnClose"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_av_icon_circle"
android:contentDescription="@string/close"
android:src="@drawable/ic_av_close"
app:tint="@color/av_text_primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/controlBar"
android:layout_width="0dp"
android:layout_height="@dimen/av_control_bar_height"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="24dp"
android:background="@drawable/bg_av_control_bar"
android:gravity="center"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:id="@+id/actionPlay"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/play_ctrl_play"
android:src="@drawable/ic_av_play"
app:tint="@color/brand_primary_text_on" />
<TextView
android:id="@+id/tvPlayLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/play_ctrl_play"
android:textColor="@color/brand_primary_text_on"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionMute"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/play_ctrl_mute"
android:src="@drawable/ic_av_volume"
app:tint="@color/brand_primary_text_on" />
<TextView
android:id="@+id/tvMuteLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/play_ctrl_mute"
android:textColor="@color/brand_primary_text_on"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -40,6 +40,23 @@
android:src="@drawable/ic_av_play"
app:tint="@color/brand_primary" />
<TextView
android:id="@+id/tvPkBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|top"
android:layout_margin="8dp"
android:background="@drawable/bg_live_pk_badge"
android:paddingStart="8dp"
android:paddingTop="2dp"
android:paddingEnd="8dp"
android:paddingBottom="2dp"
android:text="PK"
android:textColor="@android:color/white"
android:textSize="11sp"
android:textStyle="bold"
android:visibility="gone" />
<TextView
android:id="@+id/tvDuration"
android:layout_width="wrap_content"