添加PK播放功能,更新相关UI和逻辑,支持主流与PK流的播放
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
5
example/src/main/res/drawable/bg_live_pk_badge.xml
Normal file
5
example/src/main/res/drawable/bg_live_pk_badge.xml
Normal 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>
|
||||
159
example/src/main/res/layout/activity_pk_play.xml
Normal file
159
example/src/main/res/layout/activity_pk_play.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user