添加点播播放功能,创建VodPlayActivity及相关UI,更新FeatureHubActivity以支持点播配置

This commit is contained in:
2026-02-12 15:05:48 +08:00
parent d8fd9e8bc9
commit 6c8d5cce2f
10 changed files with 1004 additions and 2 deletions

Binary file not shown.

View File

@@ -64,6 +64,14 @@
android:supportsPictureInPicture="true"
android:parentActivityName=".FeatureHubActivity" />
<activity
android:name=".vod.VodPlayActivity"
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=".interactive.InteractiveLiveActivity"
android:exported="false"

View File

@@ -16,6 +16,7 @@ import com.demo.SellyCloudSDK.avdemo.AvDemoSettingsStore
import com.demo.SellyCloudSDK.databinding.ActivityFeatureHubBinding
import com.demo.SellyCloudSDK.databinding.DialogLivePresetSettingsBinding
import com.demo.SellyCloudSDK.databinding.DialogPlayConfigOverlayBinding
import com.demo.SellyCloudSDK.databinding.DialogVodInputBinding
import com.demo.SellyCloudSDK.databinding.DialogPushProtocolSheetBinding
import com.demo.SellyCloudSDK.interactive.InteractiveLiveActivity
import com.demo.SellyCloudSDK.live.LivePlayActivity
@@ -30,6 +31,7 @@ import com.demo.SellyCloudSDK.live.square.AliveStreamAdapter
import com.demo.SellyCloudSDK.live.square.AliveStreamItem
import com.demo.SellyCloudSDK.login.DemoLoginStore
import com.demo.SellyCloudSDK.login.LoginActivity
import com.demo.SellyCloudSDK.vod.VodPlayActivity
import com.sellycloud.sellycloudsdk.SellyLiveMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -118,6 +120,9 @@ class FeatureHubActivity : AppCompatActivity() {
binding.btnHomeLivePull.setOnClickListener {
showPlayConfigDialog()
}
binding.btnHomeVod.setOnClickListener {
showVodConfigDialog()
}
binding.btnCallSingleChat.setOnClickListener {
startInteractive(InteractiveLiveActivity.DEFAULT_CALL_TYPE_P2P)
}
@@ -390,6 +395,27 @@ class FeatureHubActivity : AppCompatActivity() {
dialog.show()
}
private fun showVodConfigDialog() {
val dialog = Dialog(this, R.style.Theme_AVDemo_Dialog_FullscreenOverlay)
val dialogBinding = DialogVodInputBinding.inflate(layoutInflater)
dialog.setContentView(dialogBinding.root)
dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
dialog.setCancelable(true)
dialogBinding.btnClose.setOnClickListener { dialog.dismiss() }
dialogBinding.btnStartVod.setOnClickListener {
val input = dialogBinding.etVodUrl.text?.toString()?.trim().orEmpty()
if (input.isEmpty()) {
Toast.makeText(this, getString(R.string.vod_config_hint), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
dialog.dismiss()
startActivity(VodPlayActivity.createIntent(this, input))
}
dialog.show()
}
private fun setupSettingsSave() {
binding.btnSaveSettings.setOnClickListener {
val settings = uiToSettingsOrNull() ?: return@setOnClickListener

View File

@@ -122,7 +122,7 @@ class LivePlayActivity : AppCompatActivity() {
SellyPlayerState.Reconnecting -> setPlayingUi(false)
SellyPlayerState.Playing -> setPlayingUi(true)
SellyPlayerState.Paused -> setPlayingUi(false)
SellyPlayerState.Stopped -> setPlayingUi(false)
SellyPlayerState.StoppedOrEnded -> setPlayingUi(false)
SellyPlayerState.Failed -> setPlayingUi(false)
SellyPlayerState.Idle -> Unit
}
@@ -722,7 +722,7 @@ class LivePlayActivity : AppCompatActivity() {
SellyPlayerState.Reconnecting -> "重连中"
SellyPlayerState.Playing -> "播放中"
SellyPlayerState.Paused -> "已暂停"
SellyPlayerState.Stopped -> "已停止"
SellyPlayerState.StoppedOrEnded -> "已停止"
SellyPlayerState.Failed -> "失败"
SellyPlayerState.Idle -> "空闲"
}

View File

@@ -0,0 +1,645 @@
package com.demo.SellyCloudSDK.vod
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.os.SystemClock
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.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat
import com.demo.SellyCloudSDK.R
import com.demo.SellyCloudSDK.databinding.ActivityVodPlayBinding
import com.demo.SellyCloudSDK.live.util.GalleryImageSaver
import com.sellycloud.sellycloudsdk.SellyLiveError
import com.sellycloud.sellycloudsdk.SellyPlayerState
import com.sellycloud.sellycloudsdk.SellyVodPlayer
import com.sellycloud.sellycloudsdk.SellyVodPlayerDelegate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class VodPlayActivity : AppCompatActivity() {
private lateinit var binding: ActivityVodPlayBinding
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var player: SellyVodPlayer? = null
private var renderView: View? = null
private var isPlaying = false
private var isMuted = false
private var currentState: SellyPlayerState = SellyPlayerState.Idle
private var durationMs: Long = 0
private var isUserSeeking = false
private var playAttemptStartElapsedMs: Long? = null
private var firstVideoFrameElapsedMs: Long? = null
private var firstVideoFrameCostMs: Long? = null
private var firstAudioFrameElapsedMs: Long? = null
private var firstAudioFrameCostMs: Long? = null
private var bufferingActive = false
private var progressJob: Job? = null
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
private val storagePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (!granted) {
Toast.makeText(this, "需要存储权限才能保存截图", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityVodPlayBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.hide()
addLogFloatingButton()
binding.btnClose.setOnClickListener { finish() }
binding.actionPlay.setOnClickListener { togglePlay() }
binding.actionMute.setOnClickListener { toggleMute() }
binding.actionScreenshot.setOnClickListener { captureCurrentFrame() }
binding.actionForward.setOnClickListener { seekForward() }
binding.seekBar.setOnSeekBarChangeListener(object : android.widget.SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: android.widget.SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
binding.tvCurrentTime.text = formatTime(progress.toLong())
}
}
override fun onStartTrackingTouch(seekBar: android.widget.SeekBar) {
isUserSeeking = true
logEvent("用户操作: 开始拖动进度条")
}
override fun onStopTrackingTouch(seekBar: android.widget.SeekBar) {
val position = seekBar.progress.toLong()
logEvent("用户操作: 跳转到 ${formatTime(position)}")
player?.seekTo(position)
isUserSeeking = false
}
})
val url = intent.getStringExtra(EXTRA_VOD_URL)?.trim().orEmpty()
if (url.isEmpty()) {
Toast.makeText(this, "请输入有效的播放地址", Toast.LENGTH_SHORT).show()
finish()
return
}
initPlayer(url)
}
override fun onDestroy() {
super.onDestroy()
logEvent("释放播放器")
logDialog?.dismiss()
progressJob?.cancel()
player?.release()
player = null
uiScope.cancel()
}
override fun onPause() {
super.onPause()
if (isPlaying) {
player?.pause()
}
}
private fun initPlayer(url: String) {
val vodPlayer = SellyVodPlayer.initWithUrl(this, url).also { client ->
client.autoPlay = true
client.delegate = object : SellyVodPlayerDelegate {
override fun playbackStateChanged(state: SellyPlayerState) {
runOnUiThread {
currentState = state
when (state) {
SellyPlayerState.Playing -> setPlayingUi(true)
SellyPlayerState.Paused,
SellyPlayerState.StoppedOrEnded,
SellyPlayerState.Failed,
SellyPlayerState.Connecting,
SellyPlayerState.Reconnecting,
SellyPlayerState.Idle -> setPlayingUi(false)
}
logEvent("状态变更: ${formatState(state)}")
}
}
override fun onPrepared(durationMs: Long) {
runOnUiThread {
this@VodPlayActivity.durationMs = durationMs
binding.seekBar.max = durationMs.toInt()
binding.tvTotalTime.text = formatTime(durationMs)
logEvent("准备完成: 总时长=${formatTime(durationMs)}")
}
}
override fun onFirstVideoFrameRendered() {
runOnUiThread {
if (firstVideoFrameElapsedMs != null) return@runOnUiThread
val startMs = playAttemptStartElapsedMs ?: return@runOnUiThread
firstVideoFrameElapsedMs = SystemClock.elapsedRealtime()
firstVideoFrameCostMs = firstVideoFrameElapsedMs!! - startMs
logEvent("首帧视频耗时=${firstVideoFrameCostMs}ms")
}
}
override fun onFirstAudioFrameRendered() {
runOnUiThread {
if (firstAudioFrameElapsedMs != null) return@runOnUiThread
val startMs = playAttemptStartElapsedMs ?: return@runOnUiThread
firstAudioFrameElapsedMs = SystemClock.elapsedRealtime()
firstAudioFrameCostMs = firstAudioFrameElapsedMs!! - startMs
logEvent("首帧音频耗时=${firstAudioFrameCostMs}ms")
}
}
override fun onSeekComplete() {
runOnUiThread {
isUserSeeking = false
logEvent("Seek 完成")
}
}
override fun onCompletion() {
runOnUiThread {
logEvent("播放完成")
}
}
override fun onBufferingUpdate(percent: Int) {
runOnUiThread {
if (percent <= 0) {
if (!bufferingActive) {
bufferingActive = true
logEvent("缓冲中")
}
return@runOnUiThread
}
if (bufferingActive) {
bufferingActive = false
logEvent("缓冲恢复")
}
}
}
override fun onError(error: SellyLiveError) {
runOnUiThread {
logEvent("错误: ${error.message}")
Toast.makeText(this@VodPlayActivity, error.message, Toast.LENGTH_SHORT).show()
}
}
}
client.setMuted(isMuted)
}
renderView = vodPlayer.attachRenderView(binding.renderContainer)
player = vodPlayer
startPlayAttempt()
vodPlayer.prepareAsync()
startProgressUpdates()
}
private fun togglePlay() {
if (isPlaying) {
logEvent("用户操作: 暂停")
player?.pause()
return
}
logEvent("用户操作: 播放")
if (currentState == SellyPlayerState.Paused) {
player?.play()
} else {
startPlayAttempt()
player?.play()
}
}
private fun toggleMute() {
isMuted = !isMuted
player?.setMuted(isMuted)
binding.tvMuteLabel.setText(if (isMuted) R.string.play_ctrl_unmute else R.string.play_ctrl_mute)
logEvent(if (isMuted) "用户操作: 静音" else "用户操作: 取消静音")
}
private fun seekForward() {
logEvent("用户操作: 快进10秒")
player?.seekBy(SEEK_FORWARD_MS)
}
private fun startProgressUpdates() {
progressJob?.cancel()
progressJob = uiScope.launch {
while (isActive) {
if (!isUserSeeking) {
val current = player?.getCurrentPositionMs() ?: 0L
val duration = player?.getDurationMs() ?: durationMs
if (duration > 0) {
if (duration != durationMs) {
durationMs = duration
binding.seekBar.max = duration.toInt()
binding.tvTotalTime.text = formatTime(duration)
}
binding.seekBar.progress = current.coerceAtMost(duration).toInt()
binding.tvCurrentTime.text = formatTime(current)
}
}
delay(500)
}
}
}
private fun setPlayingUi(playing: Boolean) {
isPlaying = playing
binding.tvPlayLabel.setText(if (playing) R.string.play_ctrl_pause else R.string.play_ctrl_play)
binding.ivPlayIcon.setImageResource(if (playing) R.drawable.ic_av_pause else R.drawable.ic_av_play)
}
private fun captureCurrentFrame() {
logEvent("用户操作: 截图")
val view = renderView
if (view == null) {
Toast.makeText(this, "当前无可截图的播放画面", Toast.LENGTH_SHORT).show()
return
}
if (!hasScreenshotWritePermission()) {
requestScreenshotWritePermission()
Toast.makeText(this, "请授权后重试截图", Toast.LENGTH_SHORT).show()
return
}
captureSurfaceViewAndSave(view, prefix = "vod")
}
private fun captureSurfaceViewAndSave(view: View, prefix: String) {
if (view.width <= 0 || view.height <= 0) {
Toast.makeText(this, "视图尚未布局完成,稍后再试", Toast.LENGTH_SHORT).show()
return
}
if (view !is android.view.SurfaceView) {
Toast.makeText(this, "当前视图不支持截图", Toast.LENGTH_SHORT).show()
return
}
val bmp = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
try {
val handler = android.os.Handler(mainLooper)
android.view.PixelCopy.request(view, bmp, { result ->
if (result == android.view.PixelCopy.SUCCESS) {
uiScope.launch(Dispatchers.IO) {
val ok = saveBitmapToGallery(bmp, prefix)
launch(Dispatchers.Main) {
Toast.makeText(this@VodPlayActivity, if (ok) "截图已保存到相册" else "保存失败", Toast.LENGTH_SHORT).show()
}
}
} else {
Toast.makeText(this, "截图失败,错误码: $result", Toast.LENGTH_SHORT).show()
}
}, handler)
} catch (e: Exception) {
Toast.makeText(this, "截图异常: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun saveBitmapToGallery(bitmap: Bitmap, prefix: String): Boolean {
val filename = "${prefix}_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}.png"
return GalleryImageSaver.savePng(this, bitmap, filename)
}
private fun hasScreenshotWritePermission(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return true
return ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
}
private fun requestScreenshotWritePermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return
storagePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
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(80)
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(16), dpToPx(16), dpToPx(16), dpToPx(16))
}
val dialogTitleView = TextView(this).apply {
text = "点播调试日志"
setTextColor(Color.parseColor("#F8FAFC"))
textSize = 16f
setTypeface(typeface, Typeface.BOLD)
}
val summaryTitle = TextView(this).apply {
text = "摘要"
setTextColor(Color.parseColor("#E2E8F0"))
textSize = 12f
}
val summaryView = TextView(this).apply {
text = buildLogSummary()
typeface = Typeface.MONOSPACE
setTextColor(Color.parseColor("#E2E8F0"))
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("#26FFFFFF"),
Color.parseColor("#14FFFFFF")
)).apply {
cornerRadius = dpToPx(10).toFloat()
setStroke(dpToPx(1), Color.parseColor("#33FFFFFF"))
}
setPadding(dpToPx(8), dpToPx(6), dpToPx(8), dpToPx(6))
addView(summaryView)
}
val logTitle = TextView(this).apply {
text = "事件日志"
setTextColor(Color.parseColor("#E2E8F0"))
textSize = 12f
}
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 buildLogSummary(): String {
val builder = StringBuilder()
builder.append("状态: ").append(formatState(currentState)).append('\n')
builder.append("是否播放中: ").append(if (isPlaying) "" else "").append('\n')
builder.append("是否静音: ").append(if (isMuted) "" else "").append('\n')
builder.append("总时长: ").append(if (durationMs > 0) formatTime(durationMs) else "--").append('\n')
builder.append("当前进度: ").append(formatTime(player?.getCurrentPositionMs() ?: 0L)).append('\n')
builder.append("首帧视频耗时(ms): ").append(firstVideoFrameCostMs ?: "未统计").append('\n')
builder.append("首帧音频耗时(ms): ").append(firstAudioFrameCostMs ?: "未统计").append('\n')
val attemptElapsed = playAttemptStartElapsedMs?.let { SystemClock.elapsedRealtime() - it }
if (attemptElapsed == null) {
builder.append("本次播放已耗时(ms): 未开始").append('\n')
} else {
builder.append("本次播放已耗时(ms): ").append(attemptElapsed).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 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 refreshLogDialogContent() {
val dialog = logDialog ?: return
if (!dialog.isShowing) return
logSummaryView?.text = buildLogSummary()
logContentView?.text = buildLogContent()
}
private fun copyLogsToClipboard() {
val content = buildString {
append("摘要\n")
append(buildLogSummary())
append("\n\n事件日志\n")
append(buildLogContent())
}
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("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 startPlayAttempt() {
playAttemptStartElapsedMs = SystemClock.elapsedRealtime()
firstVideoFrameElapsedMs = null
firstVideoFrameCostMs = null
firstAudioFrameElapsedMs = null
firstAudioFrameCostMs = null
bufferingActive = false
logEvent("播放尝试开始")
}
private fun formatState(state: SellyPlayerState): String {
return "${stateLabel(state)}(${state.name})"
}
private fun stateLabel(state: SellyPlayerState): String {
return when (state) {
SellyPlayerState.Connecting -> "连接中"
SellyPlayerState.Reconnecting -> "重连中"
SellyPlayerState.Playing -> "播放中"
SellyPlayerState.Paused -> "已暂停"
SellyPlayerState.StoppedOrEnded -> "已停止"
SellyPlayerState.Failed -> "失败"
SellyPlayerState.Idle -> "空闲"
}
}
private fun formatTime(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val seconds = totalSeconds % 60
val minutes = (totalSeconds / 60) % 60
val hours = totalSeconds / 3600
return if (hours > 0) {
String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
}
}
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
)
}
}
companion object {
private const val EXTRA_VOD_URL = "extra_vod_url"
private const val MAX_LOG_LINES = 200
private const val SEEK_FORWARD_MS = 10_000L
fun createIntent(context: Context, url: String): Intent {
return Intent(context, VodPlayActivity::class.java).apply {
putExtra(EXTRA_VOD_URL, url)
}
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M6,5h4v14H6zM14,5h4v14h-4z" />
</vector>

View File

@@ -110,6 +110,40 @@
android:textSize="15sp"
android:textStyle="bold" />
</LinearLayout>
<View
android:layout_width="12dp"
android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/btnHomeVod"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_av_home_button"
android:clickable="true"
android:clipToOutline="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:contentDescription="@string/home_vod"
android:src="@drawable/ic_av_play"
app:tint="@color/brand_primary_text_on" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/home_vod"
android:textColor="@color/brand_primary_text_on"
android:textSize="15sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<TextView

View File

@@ -0,0 +1,201 @@
<?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/renderContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/progressRow"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<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/progressRow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:gravity="center_vertical"
android:orientation="horizontal"
app:layout_constraintBottom_toTopOf="@id/controlBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<TextView
android:id="@+id/tvCurrentTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00"
android:textColor="@color/brand_primary_text_on"
android:textSize="12sp" />
<SeekBar
android:id="@+id/seekBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/tvTotalTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00"
android:textColor="@color/brand_primary_text_on"
android:textSize="12sp" />
</LinearLayout>
<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:id="@+id/ivPlayIcon"
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
android:id="@+id/actionScreenshot"
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_screenshot"
android:src="@drawable/ic_av_camera"
app:tint="@color/brand_primary_text_on" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/play_ctrl_screenshot"
android:textColor="@color/brand_primary_text_on"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionForward"
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_forward_10"
android:src="@drawable/ic_av_replay_10"
app:tint="@color/brand_primary_text_on" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/play_ctrl_forward_10"
android:textColor="@color/brand_primary_text_on"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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_overlay_dim">
<ImageButton
android:id="@+id/btnClose"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="top|end"
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" />
<LinearLayout
android:id="@+id/card"
android:layout_width="320dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_av_dialog_card_gray"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="16dp"
android:paddingEnd="18dp"
android:paddingBottom="18dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/vod_config_title"
android:textColor="@color/av_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/vod_config_hint"
android:textColor="@color/av_text_secondary"
android:textSize="12sp" />
<EditText
android:id="@+id/etVodUrl"
android:layout_width="match_parent"
android:layout_height="@dimen/av_field_height"
android:layout_marginTop="8dp"
android:background="@drawable/bg_av_input_field"
android:importantForAutofill="no"
android:inputType="textUri"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:textColor="@color/av_text_primary"
android:textColorHint="@color/av_text_hint"
android:textSize="14sp" />
<Button
android:id="@+id/btnStartVod"
android:layout_width="match_parent"
android:layout_height="@dimen/av_primary_button_height"
android:layout_marginTop="18dp"
android:background="@drawable/selector_av_primary_button"
android:text="@string/play_start"
android:textColor="@color/brand_primary_text_on"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</FrameLayout>

View File

@@ -31,6 +31,7 @@
<string name="home_live_push">开始直播</string>
<string name="home_live_pull">自定义播放</string>
<string name="home_vod">点播播放</string>
<string name="home_single_chat">音视频单聊</string>
<string name="home_conference">音视频会议</string>
<string name="multi_play">多路播放</string>
@@ -81,6 +82,8 @@
<string name="play_config_stream_hint">Stream ID / URL。请输入 Stream ID 或完整 URL</string>
<string name="play_start">开始播放</string>
<string name="close">关闭</string>
<string name="vod_config_title">点播播放</string>
<string name="vod_config_hint">请输入 MP4 / HLS URL</string>
<string name="protocol_rtmp">RTMP</string>
<string name="protocol_rtc">RTC</string>
@@ -97,6 +100,7 @@
<string name="play_ctrl_unmute">取消静音</string>
<string name="play_ctrl_screenshot">截图</string>
<string name="play_ctrl_pip">画中画</string>
<string name="play_ctrl_forward_10">快进10秒</string>
<string name="live_play_foreground_title">正在播放</string>
<string name="live_play_foreground_text">直播播放保持中</string>