添加PK播放功能,更新相关UI和逻辑,支持主流与PK流的播放
This commit is contained in:
@@ -64,6 +64,14 @@
|
|||||||
android:supportsPictureInPicture="true"
|
android:supportsPictureInPicture="true"
|
||||||
android:parentActivityName=".FeatureHubActivity" />
|
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
|
<activity
|
||||||
android:name=".vod.VodPlayActivity"
|
android:name=".vod.VodPlayActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import com.demo.SellyCloudSDK.databinding.DialogPushProtocolSheetBinding
|
|||||||
import com.demo.SellyCloudSDK.interactive.InteractiveLiveActivity
|
import com.demo.SellyCloudSDK.interactive.InteractiveLiveActivity
|
||||||
import com.demo.SellyCloudSDK.live.LivePlayActivity
|
import com.demo.SellyCloudSDK.live.LivePlayActivity
|
||||||
import com.demo.SellyCloudSDK.live.LivePushActivity
|
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.LiveEnvSettings
|
||||||
import com.demo.SellyCloudSDK.live.env.LiveEnvSettingsStore
|
import com.demo.SellyCloudSDK.live.env.LiveEnvSettingsStore
|
||||||
import com.demo.SellyCloudSDK.live.env.normalizedAppName
|
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.AliveListResult
|
||||||
import com.demo.SellyCloudSDK.live.square.AliveStreamAdapter
|
import com.demo.SellyCloudSDK.live.square.AliveStreamAdapter
|
||||||
import com.demo.SellyCloudSDK.live.square.AliveStreamItem
|
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.DemoLoginStore
|
||||||
import com.demo.SellyCloudSDK.login.LoginActivity
|
import com.demo.SellyCloudSDK.login.LoginActivity
|
||||||
import com.demo.SellyCloudSDK.vod.VodPlayActivity
|
import com.demo.SellyCloudSDK.vod.VodPlayActivity
|
||||||
@@ -207,6 +209,12 @@ class FeatureHubActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun handleAliveItemClick(item: AliveStreamItem) {
|
private fun handleAliveItemClick(item: AliveStreamItem) {
|
||||||
LivePlayActivity.closePipIfAny()
|
LivePlayActivity.closePipIfAny()
|
||||||
|
|
||||||
|
if (item.isPkStream) {
|
||||||
|
handlePkItemClick(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val url = item.url?.trim().orEmpty()
|
val url = item.url?.trim().orEmpty()
|
||||||
val intent = if (url.isNotEmpty()) {
|
val intent = if (url.isNotEmpty()) {
|
||||||
LivePlayActivity.createIntent(
|
LivePlayActivity.createIntent(
|
||||||
@@ -236,6 +244,28 @@ class FeatureHubActivity : AppCompatActivity() {
|
|||||||
startActivity(intent)
|
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 {
|
private fun resolvePlayMode(raw: String?): SellyLiveMode {
|
||||||
val normalized = raw?.trim()?.uppercase() ?: ""
|
val normalized = raw?.trim()?.uppercase() ?: ""
|
||||||
return if (normalized == "WHEP" || normalized == "WHIP" || normalized == "RTC") {
|
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 url: String?,
|
||||||
val previewImage: String?,
|
val previewImage: String?,
|
||||||
val durationSeconds: Long?,
|
val durationSeconds: Long?,
|
||||||
val playProtocol: String?
|
val playProtocol: String?,
|
||||||
|
val streamPk: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val AliveStreamItem.isPkStream: Boolean
|
||||||
|
get() = !streamPk.isNullOrBlank()
|
||||||
|
|
||||||
object AliveListRepository {
|
object AliveListRepository {
|
||||||
private val client = OkHttpClient()
|
private val client = OkHttpClient()
|
||||||
|
|
||||||
@@ -94,6 +98,9 @@ private fun JSONObject.toAliveItem(): AliveStreamItem {
|
|||||||
.ifBlank { optString("protocol") }
|
.ifBlank { optString("protocol") }
|
||||||
.ifBlank { optString("playProtocol") }
|
.ifBlank { optString("playProtocol") }
|
||||||
.takeIf { it.isNotBlank() }
|
.takeIf { it.isNotBlank() }
|
||||||
|
val streamPk = optString("stream_pk")
|
||||||
|
.ifBlank { optString("streamPk") }
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
return AliveStreamItem(
|
return AliveStreamItem(
|
||||||
vhost = vhost,
|
vhost = vhost,
|
||||||
@@ -102,6 +109,7 @@ private fun JSONObject.toAliveItem(): AliveStreamItem {
|
|||||||
url = url,
|
url = url,
|
||||||
previewImage = previewImage,
|
previewImage = previewImage,
|
||||||
durationSeconds = durationSeconds,
|
durationSeconds = durationSeconds,
|
||||||
playProtocol = playProtocol
|
playProtocol = playProtocol,
|
||||||
|
streamPk = streamPk
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class AliveStreamAdapter(
|
|||||||
val title = item.stream ?: "-"
|
val title = item.stream ?: "-"
|
||||||
binding.tvStreamName.text = title
|
binding.tvStreamName.text = title
|
||||||
|
|
||||||
|
binding.tvPkBadge.visibility = if (item.isPkStream) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
val protocol = item.playProtocol
|
val protocol = item.playProtocol
|
||||||
?.trim()
|
?.trim()
|
||||||
?.uppercase(Locale.getDefault())
|
?.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"
|
android:src="@drawable/ic_av_play"
|
||||||
app:tint="@color/brand_primary" />
|
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
|
<TextView
|
||||||
android:id="@+id/tvDuration"
|
android:id="@+id/tvDuration"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|||||||
Reference in New Issue
Block a user