diff --git a/example/libs/sellycloudsdk-1.0.0.aar b/example/libs/sellycloudsdk-1.0.0.aar
index 0a10475..2cf5902 100644
Binary files a/example/libs/sellycloudsdk-1.0.0.aar and b/example/libs/sellycloudsdk-1.0.0.aar differ
diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml
index 9f2d07e..dde2f3d 100644
--- a/example/src/main/AndroidManifest.xml
+++ b/example/src/main/AndroidManifest.xml
@@ -64,6 +64,14 @@
android:supportsPictureInPicture="true"
android:parentActivityName=".FeatureHubActivity" />
+
+
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 -> "空闲"
}
diff --git a/example/src/main/java/com/demo/SellyCloudSDK/vod/VodPlayActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/vod/VodPlayActivity.kt
new file mode 100644
index 0000000..15f7a3a
--- /dev/null
+++ b/example/src/main/java/com/demo/SellyCloudSDK/vod/VodPlayActivity.kt
@@ -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 = 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)
+ }
+ }
+ }
+}
diff --git a/example/src/main/res/drawable/ic_av_pause.xml b/example/src/main/res/drawable/ic_av_pause.xml
new file mode 100644
index 0000000..be64c25
--- /dev/null
+++ b/example/src/main/res/drawable/ic_av_pause.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/layout/activity_feature_hub.xml b/example/src/main/res/layout/activity_feature_hub.xml
index b0aab96..a4141f2 100644
--- a/example/src/main/res/layout/activity_feature_hub.xml
+++ b/example/src/main/res/layout/activity_feature_hub.xml
@@ -110,6 +110,40 @@
android:textSize="15sp"
android:textStyle="bold" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/src/main/res/layout/dialog_vod_input.xml b/example/src/main/res/layout/dialog_vod_input.xml
new file mode 100644
index 0000000..968ac32
--- /dev/null
+++ b/example/src/main/res/layout/dialog_vod_input.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml
index 8f7a198..ecf9194 100644
--- a/example/src/main/res/values/strings.xml
+++ b/example/src/main/res/values/strings.xml
@@ -31,6 +31,7 @@
开始直播
自定义播放
+ 点播播放
音视频单聊
音视频会议
多路播放
@@ -81,6 +82,8 @@
Stream ID / URL。请输入 Stream ID 或完整 URL
开始播放
关闭
+ 点播播放
+ 请输入 MP4 / HLS URL
RTMP
RTC
@@ -97,6 +100,7 @@
取消静音
截图
画中画
+ 快进10秒
正在播放
直播播放保持中