添加直播模块相关功能,包括登录、播放设置及环境配置管理

This commit is contained in:
2026-01-12 18:08:33 +08:00
parent eb0c23c11b
commit 951b473ec8
14 changed files with 2490 additions and 1 deletions

View File

@@ -0,0 +1,66 @@
package com.demo.SellyCloudSDK.avdemo
import android.content.Context
import androidx.core.content.edit
data class AvDemoSettings(
val streamId: String,
val resolution: Resolution,
val fps: Int,
val maxBitrateKbps: Int,
val minBitrateKbps: Int,
) {
enum class Resolution { P360, P480, P540, P720 }
fun resolutionSize(): Pair<Int, Int> = when (resolution) {
Resolution.P360 -> 640 to 360
Resolution.P480 -> 854 to 480
Resolution.P540 -> 960 to 540
Resolution.P720 -> 1280 to 720
}
}
class AvDemoSettingsStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun read(): AvDemoSettings {
val resolution = when (prefs.getString(KEY_RESOLUTION, AvDemoSettings.Resolution.P720.name)) {
AvDemoSettings.Resolution.P360.name -> AvDemoSettings.Resolution.P360
AvDemoSettings.Resolution.P480.name -> AvDemoSettings.Resolution.P480
AvDemoSettings.Resolution.P540.name -> AvDemoSettings.Resolution.P540
else -> AvDemoSettings.Resolution.P720
}
return AvDemoSettings(
streamId = prefs.getString(KEY_STREAM_ID, DEFAULT_STREAM_ID).orEmpty(),
resolution = resolution,
fps = prefs.getInt(KEY_FPS, DEFAULT_FPS),
maxBitrateKbps = prefs.getInt(KEY_MAX_KBPS, DEFAULT_MAX_KBPS),
minBitrateKbps = prefs.getInt(KEY_MIN_KBPS, DEFAULT_MIN_KBPS)
)
}
fun write(settings: AvDemoSettings) {
prefs.edit {
putString(KEY_STREAM_ID, settings.streamId)
putString(KEY_RESOLUTION, settings.resolution.name)
putInt(KEY_FPS, settings.fps)
putInt(KEY_MAX_KBPS, settings.maxBitrateKbps)
putInt(KEY_MIN_KBPS, settings.minBitrateKbps)
}
}
companion object {
private const val PREF_NAME = "avdemo_settings"
private const val KEY_STREAM_ID = "stream_id"
private const val KEY_RESOLUTION = "resolution"
private const val KEY_FPS = "fps"
private const val KEY_MAX_KBPS = "max_kbps"
private const val KEY_MIN_KBPS = "min_kbps"
private const val DEFAULT_STREAM_ID = "stream001"
private const val DEFAULT_FPS = 30
private const val DEFAULT_MAX_KBPS = 2000
private const val DEFAULT_MIN_KBPS = 500
}
}

View File

@@ -0,0 +1,793 @@
package com.demo.SellyCloudSDK.live
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.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.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 coil.load
import com.demo.SellyCloudSDK.R
import com.demo.SellyCloudSDK.databinding.ActivityLivePlayBinding
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.demo.SellyCloudSDK.live.env.toLiveMode
import com.demo.SellyCloudSDK.live.util.GalleryImageSaver
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 kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LivePlayActivity : AppCompatActivity() {
private lateinit var binding: ActivityLivePlayBinding
private lateinit var envStore: LiveEnvSettingsStore
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var args: Args
private lateinit var playerClient: SellyLiveVideoPlayer
private var isPlaying: Boolean = false
private var isMuted: Boolean = false
private var previewImageUrl: String? = null
private var hasFirstVideoFrameRendered: Boolean = false
private var currentState: SellyPlayerState = SellyPlayerState.Idle
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 isLatencyChasingActive: Boolean = false
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 val storagePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (!granted) {
Toast.makeText(this, "需要存储权限才能保存截图", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLivePlayBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.hide()
addLogFloatingButton()
envStore = LiveEnvSettingsStore(this)
val env = envStore.read().also { it.applyToSdkRuntimeConfig(this) }
args = Args.from(intent, env)
Log.d(TAG, "init liveMode=${args.liveMode} input=${args.streamIdOrUrl} autoStart=${args.autoStart}")
setupPreview(args.previewImageUrl)
playerClient = createPlayerForArgs(args).also { client ->
client.delegate = object : SellyLiveVideoPlayerDelegate {
override fun playbackStateChanged(state: SellyPlayerState) {
runOnUiThread {
currentState = state
when (state) {
SellyPlayerState.Connecting -> setPlayingUi(false)
SellyPlayerState.Reconnecting -> setPlayingUi(false)
SellyPlayerState.Playing -> setPlayingUi(true)
SellyPlayerState.Paused -> setPlayingUi(false)
SellyPlayerState.Stopped -> setPlayingUi(false)
SellyPlayerState.Failed -> setPlayingUi(false)
SellyPlayerState.Idle -> Unit
}
updatePreviewVisibility()
logEvent("状态变更: ${formatState(state)}")
if (state == SellyPlayerState.Playing && firstVideoFrameElapsedMs == null) {
val startMs = playAttemptStartElapsedMs
firstVideoFrameElapsedMs = SystemClock.elapsedRealtime()
if (startMs != null) {
firstVideoFrameCostMs = firstVideoFrameElapsedMs!! - startMs
logEvent("首帧视频耗时=${firstVideoFrameCostMs}ms")
}
}
}
}
override fun onFirstVideoFrameRendered() {
runOnUiThread {
hasFirstVideoFrameRendered = true
updatePreviewVisibility()
}
}
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 onLatencyChasingUpdate(update: SellyLatencyChasingUpdate) {
runOnUiThread {
val speedRounded = kotlin.math.round(update.speed * 10f) / 10f
val isChasing = speedRounded > 1.0f
if (isChasing && !isLatencyChasingActive) {
isLatencyChasingActive = true
val speedText = String.format(Locale.US, "%.1f", speedRounded)
logEvent("追帧开始: 速度=${speedText}x")
} else if (!isChasing && isLatencyChasingActive) {
isLatencyChasingActive = false
logEvent("追帧结束: 速度=1.0x")
}
}
}
override fun onLatencyChasingReloadRequired(latencyMs: Long) {
runOnUiThread {
logEvent("追帧触发重载: 延迟=${latencyMs}ms")
}
}
override fun onError(error: com.sellycloud.sellycloudsdk.SellyLiveError) {
runOnUiThread {
logEvent("错误: ${error.message}")
Toast.makeText(this@LivePlayActivity, error.message, Toast.LENGTH_SHORT).show()
}
}
}
client.setMuted(isMuted)
}
binding.btnClose.setOnClickListener { finish() }
binding.actionPlay.setOnClickListener { togglePlay() }
binding.actionMute.setOnClickListener { toggleMute() }
binding.actionScreenshot.setOnClickListener { captureCurrentFrame() }
binding.actionSeek10.setOnClickListener { seekForward10s() }
playerClient.attachRenderView(binding.renderContainer)
if (args.autoStart) {
startPlayback()
}
}
override fun onDestroy() {
super.onDestroy()
logEvent("释放播放器")
logDialog?.dismiss()
if (this::playerClient.isInitialized) {
playerClient.release()
}
uiScope.cancel()
}
private fun togglePlay() {
if (isPlaying) {
logEvent("用户操作: 暂停")
playerClient.pause()
return
}
logEvent("用户操作: 播放")
if (currentState == SellyPlayerState.Paused) {
playerClient.play()
} else {
startPlayback()
}
}
private fun toggleMute() {
isMuted = !isMuted
playerClient.setMuted(isMuted)
binding.tvMuteLabel.setText(if (isMuted) R.string.play_ctrl_unmute else R.string.play_ctrl_mute)
logEvent(if (isMuted) "用户操作: 静音" else "用户操作: 取消静音")
}
private fun seekForward10s() {
logEvent("用户操作: 快进10秒")
if (args.liveMode == SellyLiveMode.RTC) {
Toast.makeText(this, "RTC 暂不支持快进", Toast.LENGTH_SHORT).show()
return
}
val ok = playerClient.seekBy(10_000L)
if (!ok) Toast.makeText(this, "当前流不支持快进", Toast.LENGTH_SHORT).show()
logEvent(if (ok) "快进结果: 成功" else "快进结果: 失败")
}
private fun startPlayback() {
val env = envStore.read()
args.playParams?.let { params ->
val stream = params.streamName.trim()
if (stream.isEmpty()) {
Toast.makeText(this, "请输入有效的播放地址", Toast.LENGTH_SHORT).show()
return
}
val channelId = resolveChannelId(stream)
val authError = LiveAuthHelper.validateAuthConfig(env, channelId)
if (authError != null) {
Toast.makeText(this, authError, Toast.LENGTH_SHORT).show()
return
}
val auth = LiveAuthHelper.buildAuthParams(
env = env,
channelId = channelId,
type = LiveTokenSigner.TokenType.PULL
)
if (auth == null) {
Toast.makeText(this, "生成 token 失败", Toast.LENGTH_SHORT).show()
return
}
Log.d(TAG, "startPlayback params liveMode=${args.liveMode} streamId=$channelId tokenPreview=${auth.tokenResult.tokenPreview}")
playerClient.token = auth.tokenResult.token
beginPlayback()
return
}
val input = args.streamIdOrUrl.trim()
if (input.isEmpty()) {
Toast.makeText(this, "请输入有效的播放地址", Toast.LENGTH_SHORT).show()
return
}
if (input.contains("://")) {
Log.d(TAG, "startPlayback directUrl=$input")
playerClient.token = null
beginPlayback()
return
}
val channelId = resolveChannelId(input)
val authError = LiveAuthHelper.validateAuthConfig(env, channelId)
if (authError != null) {
Toast.makeText(this, authError, Toast.LENGTH_SHORT).show()
return
}
val auth = LiveAuthHelper.buildAuthParams(
env = env,
channelId = channelId,
type = LiveTokenSigner.TokenType.PULL
)
if (auth == null) {
Toast.makeText(this, "生成 token 失败", Toast.LENGTH_SHORT).show()
return
}
Log.d(TAG, "startPlayback liveMode=${args.liveMode} streamId=$channelId tokenPreview=${auth.tokenResult.tokenPreview}")
playerClient.token = auth.tokenResult.token
beginPlayback()
}
private fun beginPlayback() {
startPlayAttempt()
resetPreviewForPlayback()
playerClient.prepareToPlay()
playerClient.play()
}
private fun setPlayingUi(playing: Boolean) {
isPlaying = playing
binding.tvPlayLabel.setText(if (playing) R.string.play_ctrl_pause else R.string.play_ctrl_play)
}
private fun setupPreview(url: String?) {
previewImageUrl = url?.trim()?.takeIf { it.isNotEmpty() }
if (previewImageUrl == null) {
binding.ivPreview.visibility = View.GONE
return
}
binding.ivPreview.visibility = View.VISIBLE
binding.ivPreview.load(previewImageUrl) {
crossfade(true)
placeholder(R.drawable.bg_av_dialog_card_gray)
error(R.drawable.bg_av_dialog_card_gray)
}
}
private fun resetPreviewForPlayback() {
hasFirstVideoFrameRendered = false
updatePreviewVisibility()
}
private fun updatePreviewVisibility() {
val shouldShow = !previewImageUrl.isNullOrBlank() && !hasFirstVideoFrameRendered
binding.ivPreview.visibility = if (shouldShow) View.VISIBLE else View.GONE
}
private fun captureCurrentFrame() {
logEvent("用户操作: 截图")
val view = playerClient.getRenderView()
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 = "play")
}
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@LivePlayActivity, 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(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)
}
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 = "播放日志"
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 buildLogSummary(): String {
val builder = StringBuilder()
builder.append("播放模式: ").append(args.liveMode.name).append('\n')
builder.append("输入: ").append(args.streamIdOrUrl).append('\n')
args.playParams?.let { params ->
builder.append("vhost: ").append(params.vhost).append('\n')
builder.append("appName: ").append(params.appName).append('\n')
builder.append("streamName: ").append(params.streamName).append('\n')
}
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("首帧视频耗时(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
isLatencyChasingActive = 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.Stopped -> "已停止"
SellyPlayerState.Failed -> "失败"
SellyPlayerState.Idle -> "空闲"
}
}
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 TAG = "LivePlayActivity"
private const val MAX_LOG_LINES = 200
const val EXTRA_PLAY_PROTOCOL = "play_protocol"
const val EXTRA_STREAM_ID_OR_URL = "stream_id_or_url"
const val EXTRA_PLAY_VHOST = "play_vhost"
const val EXTRA_PLAY_APP_NAME = "play_app_name"
const val EXTRA_PLAY_STREAM_NAME = "play_stream_name"
const val EXTRA_AUTO_START = "auto_start"
const val EXTRA_PREVIEW_IMAGE_URL = "preview_image_url"
fun createIntent(
context: Context,
liveMode: SellyLiveMode,
streamIdOrUrl: String,
autoStart: Boolean = true
): Intent {
return Intent(context, LivePlayActivity::class.java)
.putExtra(EXTRA_PLAY_PROTOCOL, liveMode.name)
.putExtra(EXTRA_STREAM_ID_OR_URL, streamIdOrUrl)
.putExtra(EXTRA_AUTO_START, autoStart)
}
fun createIntentWithParams(
context: Context,
liveMode: SellyLiveMode,
vhost: String,
appName: String,
streamName: String,
autoStart: Boolean = true
): Intent {
return Intent(context, LivePlayActivity::class.java)
.putExtra(EXTRA_PLAY_PROTOCOL, liveMode.name)
.putExtra(EXTRA_PLAY_VHOST, vhost)
.putExtra(EXTRA_PLAY_APP_NAME, appName)
.putExtra(EXTRA_PLAY_STREAM_NAME, streamName)
.putExtra(EXTRA_AUTO_START, autoStart)
}
}
private fun resolveChannelId(input: String): String {
val trimmed = input.trim()
if (!trimmed.contains("://")) return trimmed
val uri = runCatching { java.net.URI(trimmed) }.getOrNull() ?: return trimmed
val rawPath = uri.rawPath.orEmpty().trim('/')
if (rawPath.isBlank()) return trimmed
val rawSegment = rawPath.substringAfterLast('/')
if (rawSegment.isBlank()) return trimmed
val safeSegment = rawSegment.replace("+", "%2B")
return runCatching { java.net.URLDecoder.decode(safeSegment, "UTF-8") }.getOrDefault(rawSegment)
}
private data class Args(
val liveMode: SellyLiveMode,
val streamIdOrUrl: String,
val autoStart: Boolean,
val playParams: PlayParams?,
val previewImageUrl: String?
) {
companion object {
fun from(intent: Intent, env: LiveEnvSettings): Args {
val rawProtocol = intent.getStringExtra(EXTRA_PLAY_PROTOCOL)
val rawStream = intent.getStringExtra(EXTRA_PLAY_STREAM_NAME).orEmpty().trim()
val playParams = if (rawStream.isNotBlank()) {
val vhost = intent.getStringExtra(EXTRA_PLAY_VHOST).orEmpty().trim()
.ifBlank { env.normalizedVhost() }
val appName = intent.getStringExtra(EXTRA_PLAY_APP_NAME).orEmpty().trim()
.ifBlank { env.normalizedAppName() }
PlayParams(vhost = vhost, appName = appName, streamName = rawStream)
} else {
null
}
val previewImageUrl = intent.getStringExtra(EXTRA_PREVIEW_IMAGE_URL)
?.trim()
?.takeIf { it.isNotEmpty() }
val input = intent.getStringExtra(EXTRA_STREAM_ID_OR_URL).orEmpty()
.ifBlank { playParams?.streamName ?: env.defaultStreamId }
val autoStart = intent.getBooleanExtra(EXTRA_AUTO_START, true)
val mode = resolveLiveMode(rawProtocol, input, env)
return Args(
liveMode = mode,
streamIdOrUrl = input,
autoStart = autoStart,
playParams = playParams,
previewImageUrl = previewImageUrl
)
}
private fun resolveLiveMode(raw: String?, input: String, env: LiveEnvSettings): SellyLiveMode {
val normalized = raw?.trim()?.uppercase()
val modeFromExtra = when (normalized) {
"RTC", "WHEP", "WHIP" -> SellyLiveMode.RTC
"RTMP" -> SellyLiveMode.RTMP
else -> null
}
if (modeFromExtra != null) return modeFromExtra
val trimmed = input.trim()
return if (trimmed.contains("://")) {
if (trimmed.lowercase().startsWith("rtmp://")) SellyLiveMode.RTMP else SellyLiveMode.RTC
} else {
env.protocol.toLiveMode()
}
}
}
}
private data class PlayParams(
val vhost: String,
val appName: String,
val streamName: String
)
private fun createPlayerForArgs(args: Args): SellyLiveVideoPlayer {
val input = args.streamIdOrUrl.trim()
return when {
args.playParams != null -> {
SellyLiveVideoPlayer.initWithStreamId(
this,
args.playParams.streamName,
liveMode = args.liveMode,
vhost = args.playParams.vhost,
appName = args.playParams.appName
)
}
input.contains("://") -> SellyLiveVideoPlayer.initWithUrl(this, input)
else -> SellyLiveVideoPlayer.initWithStreamId(this, input, liveMode = args.liveMode)
}
}
}

View File

@@ -0,0 +1,852 @@
package com.demo.SellyCloudSDK.live
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat
import com.demo.SellyCloudSDK.avdemo.AvDemoSettings
import com.demo.SellyCloudSDK.avdemo.AvDemoSettingsStore
import com.demo.SellyCloudSDK.beauty.FaceUnityBeautyEngine
import com.demo.SellyCloudSDK.databinding.ActivityLivePushBinding
import com.demo.SellyCloudSDK.databinding.DialogLivePushSettingsBinding
import com.demo.SellyCloudSDK.live.auth.LiveAuthHelper
import com.demo.SellyCloudSDK.live.auth.LiveTokenSigner
import com.demo.SellyCloudSDK.live.env.LiveEnvSettingsStore
import com.demo.SellyCloudSDK.live.env.applyToSdkRuntimeConfig
import com.demo.SellyCloudSDK.live.env.toLiveMode
import com.demo.SellyCloudSDK.live.util.GalleryImageSaver
import com.sellycloud.sellycloudsdk.CpuUsage
import com.sellycloud.sellycloudsdk.SellyLiveCameraPosition
import com.sellycloud.sellycloudsdk.SellyLiveMode
import com.sellycloud.sellycloudsdk.SellyLiveOrientation
import com.sellycloud.sellycloudsdk.SellyLivePusherStats
import com.sellycloud.sellycloudsdk.SellyLiveStatus
import com.sellycloud.sellycloudsdk.SellyLiveVideoConfiguration
import com.sellycloud.sellycloudsdk.SellyLiveVideoPusher
import com.sellycloud.sellycloudsdk.SellyLiveVideoPusherDelegate
import com.sellycloud.sellycloudsdk.SellyLiveVideoResolution
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.roundToInt
class LivePushActivity : AppCompatActivity() {
private lateinit var binding: ActivityLivePushBinding
private lateinit var settingsStore: AvDemoSettingsStore
private lateinit var envStore: LiveEnvSettingsStore
private lateinit var args: Args
private lateinit var pusherClient: SellyLiveVideoPusher
private var isPublishing: Boolean = false
private var isStatsCollapsed: Boolean = false
private var latestStats: SellyLivePusherStats? = null
private var isMuted: Boolean = false
private var beautyEnabled: Boolean = true
private var beautyAvailable: Boolean = true
private var beautyEngine: FaceUnityBeautyEngine? = null
private var videoSourceMode: VideoSourceMode = VideoSourceMode.Camera
private var streamOrientation: SellyLiveOrientation = SellyLiveOrientation.PORTRAIT
private var currentFacing: SellyLiveCameraPosition = SellyLiveCameraPosition.FRONT
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private var hasNavigatedHome: Boolean = false
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
val granted = REQUIRED_PERMISSIONS.all { results[it] == true }
if (granted) {
startPusher()
} else {
Toast.makeText(this, "需要相机和麦克风权限才能推流", Toast.LENGTH_LONG).show()
finish()
}
}
private val storagePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (!granted) {
Toast.makeText(this, "需要存储权限才能保存截图", Toast.LENGTH_SHORT).show()
}
}
private val pickBackgroundImageLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
if (uri == null) return@registerForActivityResult
applyBackgroundImage(uri)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
applyUiOrientation()
binding = ActivityLivePushBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.hide()
settingsStore = AvDemoSettingsStore(this)
envStore = LiveEnvSettingsStore(this)
val env = envStore.read().also { it.applyToSdkRuntimeConfig(this) }
args = Args.from(intent, defaultMode = env.protocol.toLiveMode())
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding.btnClose.setOnClickListener {
pusherOrNull()?.stopLive()
navigateHomeAfterStop()
}
binding.btnPushSettings.setOnClickListener { showPushSettingsDialog() }
binding.btnStartStopLive.setOnClickListener {
val pusher = pusherOrNull() ?: return@setOnClickListener
if (isPublishing) {
pusher.stopLive()
} else {
val settings = settingsStore.read()
val env = envStore.read()
val streamId = settings.streamId
val authError = LiveAuthHelper.validateAuthConfig(env, streamId)
if (authError != null) {
Toast.makeText(this, authError, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val auth = LiveAuthHelper.buildAuthParams(
env = env,
channelId = streamId,
type = LiveTokenSigner.TokenType.PUSH
)
applyStreamConfig(settings)
pusher.token = auth?.tokenResult?.token
pusher.startLiveWithStreamId(streamId)
}
}
binding.actionFlip.setOnClickListener { switchCameraAndRemember() }
binding.actionMute.setOnClickListener { toggleMute() }
binding.actionCamera.setOnClickListener { toggleCamera() }
binding.actionScreenshot.setOnClickListener { captureCurrentFrame() }
binding.actionBackground.setOnClickListener { toggleOrPickBackground() }
binding.actionBeauty.setOnClickListener { toggleBeauty() }
binding.btnQuickFlip.setOnClickListener { switchCameraAndRemember() }
binding.btnQuickOrientation.setOnClickListener { toggleStreamOrientation() }
binding.ivStatsCollapse.setOnClickListener { toggleStats() }
binding.tvStatsTitle.setOnClickListener { toggleStats() }
renderToolStates()
updateStreamOrientationUi()
updatePublishingUi()
updateLayoutForOrientationAndState()
if (hasRequiredPermissions()) {
startPusher()
} else {
permissionLauncher.launch(REQUIRED_PERMISSIONS)
}
}
override fun onDestroy() {
super.onDestroy()
pusherOrNull()?.release()
uiScope.cancel()
}
override fun onPause() {
super.onPause()
pusherOrNull()?.onPause()
}
override fun onResume() {
super.onResume()
pusherOrNull()?.onResume()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateLayoutForOrientationAndState()
}
private fun startPusher() {
val settings = settingsStore.read()
envStore.read().also { it.applyToSdkRuntimeConfig(this) }
pusherClient = SellyLiveVideoPusher.initWithLiveMode(this, args.liveMode).also { client ->
client.delegate = object : SellyLiveVideoPusherDelegate {
override fun liveStatusDidChanged(status: SellyLiveStatus) {
runOnUiThread { onStateUpdated(status) }
}
override fun onStatisticsUpdate(stats: SellyLivePusherStats) {
latestStats = stats
runOnUiThread { updateStatsFromStats(stats) }
}
override fun onError(error: com.sellycloud.sellycloudsdk.SellyLiveError) {
runOnUiThread { Toast.makeText(this@LivePushActivity, error.message, Toast.LENGTH_SHORT).show() }
}
}
client.setMuted(isMuted)
}
setupBeautyEngine(pusherClient)
try {
val videoConfig = buildVideoConfig(settings)
pusherClient.attachPreview(binding.previewContainer)
pusherClient.startRunning(currentFacing, videoConfig, null)
} catch (t: Throwable) {
Toast.makeText(this, "初始化预览失败: ${t.message}", Toast.LENGTH_LONG).show()
}
startCpuLoop()
}
private fun applyStreamConfig(settings: AvDemoSettings) {
val pusher = pusherOrNull() ?: return
val config = buildVideoConfig(settings)
val (width, height) = resolveStreamSize(settings)
pusher.setVideoConfiguration(config)
pusher.changeResolution(width, height)
pusher.setStreamOrientation(streamOrientation)
}
private fun resolveStreamSize(settings: AvDemoSettings): Pair<Int, Int> {
return settings.resolutionSize()
}
private fun buildVideoConfig(settings: AvDemoSettings): SellyLiveVideoConfiguration {
val resolution = when (settings.resolution) {
AvDemoSettings.Resolution.P360 -> SellyLiveVideoResolution.RES_640x360
AvDemoSettings.Resolution.P480 -> SellyLiveVideoResolution.RES_854x480
AvDemoSettings.Resolution.P540 -> SellyLiveVideoResolution.RES_960x540
AvDemoSettings.Resolution.P720 -> SellyLiveVideoResolution.RES_1280x720
}
return SellyLiveVideoConfiguration.defaultConfiguration().apply {
videoSize = resolution
videoFrameRate = settings.fps
videoBitRate = settings.maxBitrateKbps * 1000
videoMinBitRate = settings.minBitrateKbps * 1000
outputImageOrientation = streamOrientation
}
}
private fun onStateUpdated(state: SellyLiveStatus) {
isPublishing = state == SellyLiveStatus.Publishing
|| state == SellyLiveStatus.Connecting
|| state == SellyLiveStatus.Reconnecting
binding.btnStartStopLive.text = getString(if (isPublishing) com.demo.SellyCloudSDK.R.string.push_stop_live else com.demo.SellyCloudSDK.R.string.push_start_live)
updatePublishingUi()
updateLayoutForOrientationAndState()
updateStreamOrientationUi()
updateStatsFromStats(latestStats)
if (state == SellyLiveStatus.Stopped || state == SellyLiveStatus.Failed) {
navigateHomeAfterStop()
}
}
private fun navigateHomeAfterStop() {
if (hasNavigatedHome || isFinishing || isDestroyed) return
hasNavigatedHome = true
val intent = Intent(this, com.demo.SellyCloudSDK.FeatureHubActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
finish()
}
private fun updateStatsFromStats(stats: SellyLivePusherStats?) {
if (stats == null) return
val fps = stats.fps?.takeIf { it >= 0 }
if (fps != null) {
binding.tvStatsFps.text = "FPS: $fps"
binding.tvStatsFps.setTextColor(
ContextCompat.getColor(
this,
if (fps <= 0) com.demo.SellyCloudSDK.R.color.av_stats_red else com.demo.SellyCloudSDK.R.color.av_stats_green
)
)
}
val rtt = stats.rttMs?.takeIf { it >= 0 }
if (rtt != null) {
binding.tvStatsRtt.text = "RTT: $rtt ms"
binding.tvStatsRtt.setTextColor(
ContextCompat.getColor(
this,
if (rtt >= 200) com.demo.SellyCloudSDK.R.color.av_stats_red else com.demo.SellyCloudSDK.R.color.av_stats_green
)
)
}
val audioKbps = stats.audioBitrateKbps?.takeIf { it >= 0 }
val videoKbps = stats.videoBitrateKbps?.takeIf { it >= 0 }
if (audioKbps != null && videoKbps != null) {
val total = audioKbps + videoKbps
binding.tvStatsKbps.text = "$total kbps (A:$audioKbps V:$videoKbps)"
binding.tvStatsKbps.setTextColor(
ContextCompat.getColor(
this,
if (total <= 0) com.demo.SellyCloudSDK.R.color.brand_primary_text_sub else com.demo.SellyCloudSDK.R.color.brand_primary_text_on
)
)
return
}
val kbps = stats.videoBitrateKbps
if (kbps != null) {
binding.tvStatsKbps.text = "$kbps kbps"
binding.tvStatsKbps.setTextColor(
ContextCompat.getColor(
this,
if (kbps <= 0) com.demo.SellyCloudSDK.R.color.brand_primary_text_sub else com.demo.SellyCloudSDK.R.color.brand_primary_text_on
)
)
}
}
private fun toggleStats() {
isStatsCollapsed = !isStatsCollapsed
binding.statsContent.visibility = if (isStatsCollapsed) View.GONE else View.VISIBLE
binding.ivStatsCollapse.rotation = if (isStatsCollapsed) 180f else 0f
}
private fun startCpuLoop() {
uiScope.launch {
while (isActive) {
val cpu = CpuUsage.getProcessPercent(minIntervalMs = 1000L)
val percent = "%.0f".format(cpu)
binding.tvStatsCpuApp.text = "App CPU: $percent%"
binding.tvStatsCpuApp.setTextColor(cpuColor(cpu))
binding.tvStatsCpuSys.text = "Sys CPU: $percent%"
binding.tvStatsCpuSys.setTextColor(ContextCompat.getColor(this@LivePushActivity, com.demo.SellyCloudSDK.R.color.av_stats_green))
updateStatsFromStats(latestStats)
delay(1000)
}
}
}
private fun cpuColor(percent: Double): Int {
val colorRes = when {
percent >= 60.0 -> com.demo.SellyCloudSDK.R.color.av_stats_red
percent >= 30.0 -> com.demo.SellyCloudSDK.R.color.av_stats_yellow
else -> com.demo.SellyCloudSDK.R.color.av_stats_green
}
return ContextCompat.getColor(this, colorRes)
}
private fun updatePublishingUi() {
binding.controlBar.visibility = if (isPublishing) View.VISIBLE else View.GONE
binding.quickActions.visibility = if (isPublishing) View.GONE else View.VISIBLE
binding.btnPushSettings.visibility = if (isPublishing) View.GONE else View.VISIBLE
}
private fun updateLayoutForOrientationAndState() {
val root = binding.root as? ConstraintLayout ?: return
val set = ConstraintSet()
set.clone(root)
val rowId = binding.bottomStartRow.id
val barrierId = binding.bottomBarrier.id
set.clear(rowId, ConstraintSet.TOP)
set.clear(rowId, ConstraintSet.BOTTOM)
set.connect(rowId, ConstraintSet.BOTTOM, barrierId, ConstraintSet.TOP, dp(if (isPublishing) 12 else 26))
set.setHorizontalBias(rowId, 0.5f)
set.applyTo(root)
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
private fun renderToolStates() {
val normal = ContextCompat.getColor(this, com.demo.SellyCloudSDK.R.color.brand_primary_text_on)
val danger = ContextCompat.getColor(this, com.demo.SellyCloudSDK.R.color.av_stats_red)
val muted = ContextCompat.getColor(this, com.demo.SellyCloudSDK.R.color.brand_primary_text_sub)
val muteActive = isMuted
binding.tvToolMuteLabel.setText(if (muteActive) com.demo.SellyCloudSDK.R.string.push_tool_unmute else com.demo.SellyCloudSDK.R.string.push_tool_mute)
binding.ivToolMute.setImageResource(if (muteActive) com.demo.SellyCloudSDK.R.drawable.ic_live_mic_off else com.demo.SellyCloudSDK.R.drawable.ic_live_mic)
val muteColor = if (muteActive) danger else normal
binding.ivToolMute.setColorFilter(muteColor)
binding.tvToolMuteLabel.setTextColor(muteColor)
val cameraOff = videoSourceMode != VideoSourceMode.Camera
binding.tvToolCameraLabel.setText(if (cameraOff) com.demo.SellyCloudSDK.R.string.push_tool_camera_on else com.demo.SellyCloudSDK.R.string.push_tool_camera_off)
binding.ivToolCamera.setImageResource(if (cameraOff) com.demo.SellyCloudSDK.R.drawable.ic_live_video_off else com.demo.SellyCloudSDK.R.drawable.ic_live_video)
val camColor = if (cameraOff) danger else normal
binding.ivToolCamera.setColorFilter(camColor)
binding.tvToolCameraLabel.setTextColor(camColor)
val bgActive = videoSourceMode is VideoSourceMode.Background
val bgColor = if (bgActive) ContextCompat.getColor(this, com.demo.SellyCloudSDK.R.color.av_stats_green) else normal
binding.ivToolBackground.setColorFilter(bgColor)
val beautyLabelRes = if (beautyEnabled) {
com.demo.SellyCloudSDK.R.string.push_tool_beauty_off
} else {
com.demo.SellyCloudSDK.R.string.push_tool_beauty_on
}
val beautyColor = if (!beautyAvailable) {
muted
} else if (beautyEnabled) {
normal
} else {
danger
}
binding.tvToolBeautyLabel.setText(if (beautyAvailable) beautyLabelRes else com.demo.SellyCloudSDK.R.string.push_tool_not_supported)
binding.ivToolBeauty.setColorFilter(beautyColor)
binding.tvToolBeautyLabel.setTextColor(beautyColor)
binding.actionBeauty.isEnabled = beautyAvailable
binding.actionBeauty.alpha = if (beautyAvailable) 1f else 0.5f
val canSwitchCamera = videoSourceMode !is VideoSourceMode.Background
binding.actionFlip.isEnabled = canSwitchCamera
binding.actionFlip.alpha = if (canSwitchCamera) 1f else 0.4f
binding.btnQuickFlip.isEnabled = canSwitchCamera
binding.btnQuickFlip.alpha = if (canSwitchCamera) 1f else 0.4f
}
private fun setupBeautyEngine(pusher: SellyLiveVideoPusher) {
if (beautyEngine != null || !beautyAvailable) return
val engine = FaceUnityBeautyEngine()
val ok = runCatching { pusher.setBeautyEngine(engine) }.isSuccess
if (ok) {
beautyEngine = engine
runCatching { pusher.setBeautyEnabled(beautyEnabled) }
} else {
beautyAvailable = false
}
renderToolStates()
}
private fun toggleBeauty() {
val pusher = pusherOrNull() ?: return
if (beautyEngine == null) {
setupBeautyEngine(pusher)
}
if (!beautyAvailable) {
Toast.makeText(this, com.demo.SellyCloudSDK.R.string.push_tool_not_supported, Toast.LENGTH_SHORT).show()
return
}
val target = !beautyEnabled
val ok = runCatching { pusher.setBeautyEnabled(target) }.isSuccess
if (!ok) {
beautyAvailable = false
renderToolStates()
Toast.makeText(this, com.demo.SellyCloudSDK.R.string.push_tool_not_supported, Toast.LENGTH_SHORT).show()
return
}
beautyEnabled = target
renderToolStates()
}
private fun toggleStreamOrientation() {
if (isPublishing) return
streamOrientation = if (streamOrientation == SellyLiveOrientation.PORTRAIT) {
SellyLiveOrientation.LANDSCAPE_RIGHT
} else {
SellyLiveOrientation.PORTRAIT
}
applyUiOrientation()
updateStreamOrientationUi()
updateLayoutForOrientationAndState()
applyStreamConfig(settingsStore.read())
(videoSourceMode as? VideoSourceMode.Background)?.let { applyBackgroundImage(it.uri) }
}
private fun updateStreamOrientationUi() {
val isPortrait = streamOrientation == SellyLiveOrientation.PORTRAIT
val labelRes = if (isPortrait) {
com.demo.SellyCloudSDK.R.string.push_stream_portrait
} else {
com.demo.SellyCloudSDK.R.string.push_stream_landscape
}
binding.btnQuickOrientation.rotation = if (isPortrait) 0f else 90f
val protocolLabel = if (args.liveMode == SellyLiveMode.RTC) "rtc" else "rtmp"
binding.tvStatsProtocol.text = "$protocolLabel | ${getString(labelRes)}"
}
private fun applyUiOrientation() {
requestedOrientation = if (streamOrientation == SellyLiveOrientation.PORTRAIT) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
}
private fun toggleMute() {
val pusher = pusherOrNull() ?: return
val target = !isMuted
runCatching { pusher.setMuted(target) }
isMuted = target
renderToolStates()
}
private fun switchCameraAndRemember() {
if (videoSourceMode is VideoSourceMode.Background) {
Toast.makeText(this, "背景图模式下无法切换摄像头", Toast.LENGTH_SHORT).show()
return
}
val pusher = pusherOrNull() ?: return
currentFacing = if (currentFacing == SellyLiveCameraPosition.FRONT) {
SellyLiveCameraPosition.BACK
} else {
SellyLiveCameraPosition.FRONT
}
pusher.switchCameraPosition(currentFacing)
}
private fun toggleCamera() {
val pusher = pusherOrNull() ?: return
when (videoSourceMode) {
VideoSourceMode.Camera -> {
runCatching { pusher.setCameraEnabled(false) }
videoSourceMode = VideoSourceMode.CameraOff
}
VideoSourceMode.CameraOff -> {
runCatching { pusher.setCameraEnabled(true) }
videoSourceMode = VideoSourceMode.Camera
}
is VideoSourceMode.Background -> {
runCatching { pusher.setCameraEnabled(false) }
videoSourceMode = VideoSourceMode.CameraOff
}
}
renderToolStates()
}
private fun toggleOrPickBackground() {
if (videoSourceMode is VideoSourceMode.Background) {
val pusher = pusherOrNull() ?: return
runCatching { pusher.restoreCameraVideoSource() }
runCatching { pusher.setCameraEnabled(true) }
videoSourceMode = VideoSourceMode.Camera
renderToolStates()
return
}
pickBackgroundImageLauncher.launch("image/*")
}
private fun applyBackgroundImage(uri: Uri) {
val pusher = pusherOrNull() ?: return
val (baseWidth, baseHeight) = settingsStore.read().resolutionSize()
val isPortrait = streamOrientation == SellyLiveOrientation.PORTRAIT
val targetWidth = if (isPortrait) baseHeight else baseWidth
val targetHeight = if (isPortrait) baseWidth else baseHeight
val maxSize = max(targetWidth, targetHeight)
val bitmap = loadBitmapFromUri(uri, maxSizePx = maxSize) ?: run {
Toast.makeText(this, "读取图片失败", Toast.LENGTH_SHORT).show()
return
}
val orientedBitmap = if (args.liveMode == SellyLiveMode.RTC || isPortrait) {
bitmap
} else {
rotateBitmapClockwise90(bitmap)
}
val scaleMode = BackgroundScaleMode.FIT
val scaled = scaleBackgroundBitmap(orientedBitmap, targetWidth, targetHeight, scaleMode)
val ok = runCatching { pusher.setBitmapAsVideoSource(scaled) }.getOrDefault(false)
if (!ok) {
Toast.makeText(this, com.demo.SellyCloudSDK.R.string.push_tool_not_supported, Toast.LENGTH_SHORT).show()
return
}
videoSourceMode = VideoSourceMode.Background(uri)
renderToolStates()
}
private enum class BackgroundScaleMode { FIT, FILL }
private fun scaleBackgroundBitmap(
src: Bitmap,
targetWidth: Int,
targetHeight: Int,
mode: BackgroundScaleMode
): Bitmap {
if (src.width == targetWidth && src.height == targetHeight) return src
val result = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
canvas.drawColor(Color.BLACK)
val srcRatio = src.width.toFloat() / src.height.toFloat()
val targetRatio = targetWidth.toFloat() / targetHeight.toFloat()
val scale = when (mode) {
BackgroundScaleMode.FILL -> {
if (srcRatio > targetRatio) {
targetHeight.toFloat() / src.height.toFloat()
} else {
targetWidth.toFloat() / src.width.toFloat()
}
}
BackgroundScaleMode.FIT -> {
if (srcRatio > targetRatio) {
targetWidth.toFloat() / src.width.toFloat()
} else {
targetHeight.toFloat() / src.height.toFloat()
}
}
}
val scaledWidth = (src.width * scale).roundToInt()
val scaledHeight = (src.height * scale).roundToInt()
val left = (targetWidth - scaledWidth) / 2
val top = (targetHeight - scaledHeight) / 2
val destRect = Rect(left, top, left + scaledWidth, top + scaledHeight)
canvas.drawBitmap(src, null, destRect, backgroundPaint)
return result
}
private fun rotateBitmapClockwise90(src: Bitmap): Bitmap {
val matrix = Matrix().apply { postRotate(90f) }
val rotated = Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true)
if (rotated !== src && !src.isRecycled) {
src.recycle()
}
return rotated
}
private fun loadBitmapFromUri(uri: Uri, maxSizePx: Int): Bitmap? {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val source = android.graphics.ImageDecoder.createSource(contentResolver, uri)
android.graphics.ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
decoder.allocator = android.graphics.ImageDecoder.ALLOCATOR_SOFTWARE
val size = info.size
val maxDim = max(size.width, size.height).coerceAtLeast(1)
if (maxDim > maxSizePx) {
val scale = maxSizePx.toFloat() / maxDim.toFloat()
decoder.setTargetSize((size.width * scale).roundToInt(), (size.height * scale).roundToInt())
}
}
} else {
val resolver = contentResolver
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { android.graphics.BitmapFactory.decodeStream(it, null, bounds) }
val maxDim = max(bounds.outWidth, bounds.outHeight).coerceAtLeast(1)
val sample = (maxDim / maxSizePx.toFloat()).coerceAtLeast(1f).toInt()
val opts = android.graphics.BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
}
resolver.openInputStream(uri)?.use { android.graphics.BitmapFactory.decodeStream(it, null, opts) }
}
} catch (_: Exception) {
null
}
}
private fun captureCurrentFrame() {
val view = pusherOrNull()?.getPreviewView()
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 = "push")
}
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@LivePushActivity,
if (ok) com.demo.SellyCloudSDK.R.string.push_tool_screenshot_saved else com.demo.SellyCloudSDK.R.string.push_tool_screenshot_failed,
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}_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.getDefault()).format(java.util.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 showPushSettingsDialog() {
val dialog = android.app.Dialog(this)
val dialogBinding = DialogLivePushSettingsBinding.inflate(layoutInflater)
dialog.setContentView(dialogBinding.root)
dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
val current = settingsStore.read()
val currentEnv = envStore.read()
dialogBinding.etStreamId.setText(current.streamId)
dialogBinding.etFps.setText(current.fps.toString())
dialogBinding.etMaxBitrate.setText(current.maxBitrateKbps.toString())
dialogBinding.etMinBitrate.setText(current.minBitrateKbps.toString())
dialogBinding.etEnvVhost.setText(currentEnv.vhost)
dialogBinding.etEnvVhostKey.setText(currentEnv.vhostKey)
dialogBinding.etEnvAppId.setText(currentEnv.appId)
dialogBinding.rgResolution.check(
when (current.resolution) {
AvDemoSettings.Resolution.P360 -> com.demo.SellyCloudSDK.R.id.rbRes360p
AvDemoSettings.Resolution.P480 -> com.demo.SellyCloudSDK.R.id.rbRes480p
AvDemoSettings.Resolution.P540 -> com.demo.SellyCloudSDK.R.id.rbRes540p
AvDemoSettings.Resolution.P720 -> com.demo.SellyCloudSDK.R.id.rbRes720p
}
)
dialogBinding.btnClose.setOnClickListener { dialog.dismiss() }
dialogBinding.btnApply.setOnClickListener {
val streamId = dialogBinding.etStreamId.text?.toString()?.trim().orEmpty()
val fps = dialogBinding.etFps.text?.toString()?.trim()?.toIntOrNull()
val maxKbps = dialogBinding.etMaxBitrate.text?.toString()?.trim()?.toIntOrNull()
val minKbps = dialogBinding.etMinBitrate.text?.toString()?.trim()?.toIntOrNull()
val vhost = dialogBinding.etEnvVhost.text?.toString()?.trim().orEmpty()
val vhostKey = dialogBinding.etEnvVhostKey.text?.toString()?.trim().orEmpty()
val appId = dialogBinding.etEnvAppId.text?.toString()?.trim().orEmpty()
if (streamId.isEmpty()) {
Toast.makeText(this, "请输入 Stream ID", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
if (fps == null || fps <= 0) {
Toast.makeText(this, "请输入正确的 FPS", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
if (maxKbps == null || maxKbps <= 0) {
Toast.makeText(this, "请输入正确的最大码率", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
if (minKbps == null || minKbps <= 0) {
Toast.makeText(this, "请输入正确的最小码率", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val res = when (dialogBinding.rgResolution.checkedRadioButtonId) {
com.demo.SellyCloudSDK.R.id.rbRes360p -> AvDemoSettings.Resolution.P360
com.demo.SellyCloudSDK.R.id.rbRes480p -> AvDemoSettings.Resolution.P480
com.demo.SellyCloudSDK.R.id.rbRes540p -> AvDemoSettings.Resolution.P540
else -> AvDemoSettings.Resolution.P720
}
val updated = current.copy(
streamId = streamId,
resolution = res,
fps = fps,
maxBitrateKbps = maxKbps,
minBitrateKbps = minKbps
)
settingsStore.write(updated)
val envUpdated = currentEnv.copy(
vhost = vhost,
vhostKey = vhostKey,
appId = appId,
defaultStreamId = streamId
)
envStore.write(envUpdated)
envUpdated.applyToSdkRuntimeConfig(this@LivePushActivity)
applyStreamConfig(updated)
dialog.dismiss()
}
dialog.show()
}
private fun hasRequiredPermissions(): Boolean {
return REQUIRED_PERMISSIONS.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
}
companion object {
const val EXTRA_LIVE_MODE = "push_live_mode"
fun createIntent(context: Context, liveMode: SellyLiveMode): Intent =
Intent(context, LivePushActivity::class.java)
.putExtra(EXTRA_LIVE_MODE, liveMode.name)
fun createIntent(context: Context, isRtc: Boolean): Intent =
createIntent(context, if (isRtc) SellyLiveMode.RTC else SellyLiveMode.RTMP)
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
}
private fun pusherOrNull(): SellyLiveVideoPusher? =
if (this::pusherClient.isInitialized) pusherClient else null
private data class Args(val liveMode: SellyLiveMode) {
companion object {
fun from(intent: Intent, defaultMode: SellyLiveMode): Args {
val raw = intent.getStringExtra(EXTRA_LIVE_MODE)
val mode = raw?.let { runCatching { SellyLiveMode.valueOf(it) }.getOrNull() } ?: defaultMode
return Args(liveMode = mode)
}
}
}
private sealed class VideoSourceMode {
object Camera : VideoSourceMode()
object CameraOff : VideoSourceMode()
data class Background(val uri: Uri) : VideoSourceMode()
}
}

View File

@@ -0,0 +1,52 @@
package com.demo.SellyCloudSDK.live.auth
import com.demo.SellyCloudSDK.live.env.LiveEnvSettings
import com.demo.SellyCloudSDK.live.env.normalizedAppId
import com.demo.SellyCloudSDK.live.env.normalizedVhost
object LiveAuthHelper {
data class AuthParamsResult(
val params: Map<String, String>,
val tokenResult: LiveTokenSigner.TokenResult
)
fun validateAuthConfig(env: LiveEnvSettings, channelId: String): String? {
if (env.normalizedVhost().isBlank()) return "请填写 VHost"
if (env.normalizedAppId().isBlank()) return "请填写 App ID"
if (env.vhostKey.isBlank()) return "请填写 VHost Key"
if (channelId.isBlank()) return "请填写 Stream ID"
return null
}
fun buildAuthParams(
env: LiveEnvSettings,
channelId: String,
type: LiveTokenSigner.TokenType,
signTimeSec: Long? = null,
ttlSeconds: Long = LiveTokenSigner.DEFAULT_TTL_SECONDS
): AuthParamsResult? {
val error = validateAuthConfig(env, channelId)
if (error != null) return null
val vhost = env.normalizedVhost()
val appId = env.normalizedAppId()
val result = LiveTokenSigner.generateToken(
vhost = vhost,
appId = appId,
channelId = channelId,
vhostKey = env.vhostKey,
type = type,
signTimeSec = signTimeSec ?: LiveTokenSigner.currentUnixTimeSeconds(),
ttlSeconds = ttlSeconds
)
return AuthParamsResult(
params = mapOf(
PARAM_VHOST to vhost,
PARAM_TOKEN to result.token
),
tokenResult = result
)
}
const val PARAM_VHOST = "vhost"
const val PARAM_TOKEN = "token"
}

View File

@@ -0,0 +1,86 @@
package com.demo.SellyCloudSDK.live.auth
import java.nio.charset.StandardCharsets
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object LiveTokenSigner {
const val DEFAULT_TTL_SECONDS: Long = 600L
enum class TokenType(val value: String) { PUSH("push"), PULL("pull") }
data class TokenResult(
val token: String,
val baseString: String,
val signatureHex: String,
val signTimeSec: Long,
val expireTimeSec: Long,
val type: TokenType
) {
val tokenPreview: String
get() = token.take(8)
}
fun generateToken(
vhost: String,
appId: String,
channelId: String,
vhostKey: String,
type: TokenType,
signTimeSec: Long = currentUnixTimeSeconds(),
ttlSeconds: Long = DEFAULT_TTL_SECONDS
): TokenResult {
val expireTime = signTimeSec + ttlSeconds
val unsigned = buildUnsignedPayload(vhost, appId, channelId, signTimeSec, expireTime, type)
val signature = signHex(vhostKey, unsigned)
val finalPayload = buildSignedPayload(unsigned, signature)
val token = base64Encode(finalPayload)
return TokenResult(
token = token,
baseString = finalPayload,
signatureHex = signature,
signTimeSec = signTimeSec,
expireTimeSec = expireTime,
type = type
)
}
internal fun buildUnsignedPayload(
vhost: String,
appId: String,
channelId: String,
signTimeSec: Long,
expireTimeSec: Long,
type: TokenType
): String = listOf(
vhost,
appId,
channelId,
signTimeSec.toString(),
expireTimeSec.toString(),
type.value
).joinToString("|")
internal fun buildSignedPayload(unsignedPayload: String, signatureHex: String): String =
"$unsignedPayload|$signatureHex"
internal fun signHex(key: String, payload: String): String {
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")
mac.init(keySpec)
val bytes = mac.doFinal(payload.toByteArray(StandardCharsets.UTF_8))
val hex = StringBuilder(bytes.size * 2)
for (b in bytes) {
hex.append(String.format("%02x", b))
}
return hex.toString()
}
internal fun base64Encode(payload: String): String {
val bytes = payload.toByteArray(StandardCharsets.UTF_8)
return Base64.getEncoder().encodeToString(bytes)
}
internal fun currentUnixTimeSeconds(): Long = System.currentTimeMillis() / 1000L
}

View File

@@ -0,0 +1,62 @@
package com.demo.SellyCloudSDK.live.auth
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object LiveUrlParamAppender {
fun appendParams(url: String, params: Map<String, String>): String {
if (params.isEmpty()) return url
val uri = runCatching { URI(url) }.getOrNull()
?.takeIf { it.scheme != null && it.rawAuthority != null }
?: return fallbackAppend(url, params)
val query = mergeQuery(uri.rawQuery.orEmpty(), params)
val base = buildString {
append(uri.scheme)
append("://")
append(uri.rawAuthority)
append(uri.rawPath)
}
val fragment = uri.rawFragment?.let { "#$it" }.orEmpty()
return if (query.isEmpty()) "$base$fragment" else "$base?$query$fragment"
}
private fun mergeQuery(rawQuery: String, params: Map<String, String>): String {
if (rawQuery.isBlank()) return buildEncodedPairs(params)
val kept = parseRawPairs(rawQuery).filterNot { it.key in params.keys }
val encoded = buildEncodedPairs(params)
val keptQuery = kept.joinToString("&") { it.rawPair }
return listOf(keptQuery, encoded).filter { it.isNotBlank() }.joinToString("&")
}
private fun fallbackAppend(url: String, params: Map<String, String>): String {
val hashIndex = url.indexOf('#')
val main = if (hashIndex >= 0) url.substring(0, hashIndex) else url
val fragment = if (hashIndex >= 0) url.substring(hashIndex) else ""
val queryIndex = main.indexOf('?')
val base = if (queryIndex >= 0) main.substring(0, queryIndex) else main
val rawQuery = if (queryIndex >= 0) main.substring(queryIndex + 1) else ""
val mergedQuery = mergeQuery(rawQuery, params)
return if (mergedQuery.isBlank()) "$base$fragment" else "$base?$mergedQuery$fragment"
}
private data class RawPair(val key: String, val rawPair: String)
private fun parseRawPairs(rawQuery: String): List<RawPair> {
if (rawQuery.isBlank()) return emptyList()
return rawQuery.split("&").mapNotNull { part ->
if (part.isBlank()) return@mapNotNull null
val idx = part.indexOf('=')
if (idx <= 0) return@mapNotNull null
val key = part.substring(0, idx)
if (key.isBlank()) return@mapNotNull null
RawPair(key = key, rawPair = part)
}
}
private fun buildEncodedPairs(params: Map<String, String>): String = params.entries.joinToString("&") { (key, value) ->
val encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8.toString())
val encodedValue = URLEncoder.encode(value, StandardCharsets.UTF_8.toString())
"$encodedKey=$encodedValue"
}
}

View File

@@ -0,0 +1,35 @@
package com.demo.SellyCloudSDK.live.env
import android.content.Context
import com.sellycloud.sellycloudsdk.SellyCloudConfig
import com.sellycloud.sellycloudsdk.SellyCloudManager
import com.sellycloud.sellycloudsdk.SellyLiveMode
fun LiveEnvSettings.applyToSdkRuntimeConfig(context: Context) {
SellyCloudManager.initialize(
context = context,
appId = appId,
config = SellyCloudConfig(
appId = appId,
appName = normalizedAppId(),
vhost = normalizedVhost(),
vhostKey = vhostKey,
defaultStreamId = defaultStreamId,
enableKiwi = enableKiwi,
kiwiRsName = kiwiRsName,
logEnabled = logEnabled,
defaultLiveMode = protocol.toLiveMode()
)
)
}
fun LiveEnvSettings.normalizedAppName(): String = normalizedAppId()
fun LiveEnvSettings.normalizedVhost(): String = vhost.ifBlank { LiveEnvSettings.DEFAULT_VHOST }
fun LiveEnvSettings.normalizedAppId(): String = appId.ifBlank { LiveEnvSettings.DEFAULT_APP_ID }
fun LiveEnvProtocol.toLiveMode(): SellyLiveMode = when (this) {
LiveEnvProtocol.RTMP -> SellyLiveMode.RTMP
LiveEnvProtocol.RTC -> SellyLiveMode.RTC
}

View File

@@ -0,0 +1,83 @@
package com.demo.SellyCloudSDK.live.env
import android.content.Context
import androidx.core.content.edit
enum class LiveEnvProtocol { RTMP, RTC }
data class LiveEnvSettings(
val protocol: LiveEnvProtocol = LiveEnvProtocol.RTMP,
val appName: String = DEFAULT_APP_NAME,
val vhost: String = DEFAULT_VHOST,
val vhostKey: String = DEFAULT_VHOST_KEY,
val appId: String = DEFAULT_APP_ID,
val defaultStreamId: String = DEFAULT_STREAM_ID,
val enableKiwi: Boolean = DEFAULT_ENABLE_KIWI,
val kiwiRsName: String = DEFAULT_KIWI_RSNAME,
val logEnabled: Boolean = DEFAULT_LOG_ENABLED
) {
companion object {
const val DEFAULT_APP_NAME = "live"
const val DEFAULT_VHOST = "rtcdemo.sellycloud.io"
const val DEFAULT_VHOST_KEY = "BcHNlErmJgw4gyM5"
const val DEFAULT_APP_ID = "live"
const val DEFAULT_STREAM_ID = "822"
const val DEFAULT_ENABLE_KIWI = true
const val DEFAULT_KIWI_RSNAME = "123"
const val DEFAULT_LOG_ENABLED = true
}
}
class LiveEnvSettingsStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun read(): LiveEnvSettings {
val protocol = when (prefs.getString(KEY_PROTOCOL, LiveEnvProtocol.RTMP.name)) {
LiveEnvProtocol.RTC.name -> LiveEnvProtocol.RTC
else -> LiveEnvProtocol.RTMP
}
val storedAppId = prefs.getString(KEY_APP_ID, LiveEnvSettings.DEFAULT_APP_ID).orEmpty()
val fallbackAppName = prefs.getString(KEY_APP_NAME, LiveEnvSettings.DEFAULT_APP_NAME).orEmpty()
val resolvedAppId = storedAppId.ifBlank {
fallbackAppName.ifBlank { LiveEnvSettings.DEFAULT_APP_ID }
}
return LiveEnvSettings(
protocol = protocol,
appName = resolvedAppId,
vhost = prefs.getString(KEY_VHOST, LiveEnvSettings.DEFAULT_VHOST).orEmpty(),
vhostKey = prefs.getString(KEY_VHOST_KEY, LiveEnvSettings.DEFAULT_VHOST_KEY).orEmpty(),
appId = resolvedAppId,
defaultStreamId = prefs.getString(KEY_DEFAULT_STREAM_ID, LiveEnvSettings.DEFAULT_STREAM_ID).orEmpty(),
enableKiwi = prefs.getBoolean(KEY_ENABLE_KIWI, LiveEnvSettings.DEFAULT_ENABLE_KIWI),
kiwiRsName = prefs.getString(KEY_KIWI_RSNAME, LiveEnvSettings.DEFAULT_KIWI_RSNAME).orEmpty(),
logEnabled = prefs.getBoolean(KEY_LOG_ENABLED, LiveEnvSettings.DEFAULT_LOG_ENABLED)
)
}
fun write(settings: LiveEnvSettings) {
prefs.edit {
putString(KEY_PROTOCOL, settings.protocol.name)
putString(KEY_APP_NAME, settings.appId)
putString(KEY_VHOST, settings.vhost)
putString(KEY_VHOST_KEY, settings.vhostKey)
putString(KEY_APP_ID, settings.appId)
putString(KEY_DEFAULT_STREAM_ID, settings.defaultStreamId)
putBoolean(KEY_ENABLE_KIWI, settings.enableKiwi)
putString(KEY_KIWI_RSNAME, settings.kiwiRsName)
putBoolean(KEY_LOG_ENABLED, settings.logEnabled)
}
}
companion object {
private const val PREF_NAME = "live_env_settings"
private const val KEY_PROTOCOL = "protocol"
private const val KEY_APP_NAME = "app_name"
private const val KEY_VHOST = "vhost"
private const val KEY_VHOST_KEY = "vhost_key"
private const val KEY_APP_ID = "app_id"
private const val KEY_DEFAULT_STREAM_ID = "default_stream_id"
private const val KEY_ENABLE_KIWI = "enable_kiwi"
private const val KEY_KIWI_RSNAME = "kiwi_rsname"
private const val KEY_LOG_ENABLED = "log_enabled"
}
}

View File

@@ -0,0 +1,107 @@
package com.demo.SellyCloudSDK.live.square
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
private const val ALIVE_LIST_URL = "http://rtmp.sellycloud.io:8089/live/sdk/alive-list"
sealed class AliveListResult {
data class Success(val response: AliveListResponse) : AliveListResult()
data class Error(val message: String) : AliveListResult()
}
data class AliveListResponse(
val success: Boolean,
val list: List<AliveStreamItem>,
val message: String?
)
data class AliveStreamItem(
val vhost: String?,
val app: String?,
val stream: String?,
val url: String?,
val previewImage: String?,
val durationSeconds: Long?,
val playProtocol: String?
)
object AliveListRepository {
private val client = OkHttpClient()
suspend fun fetchAliveList(): AliveListResult = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(ALIVE_LIST_URL)
.get()
.build()
try {
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (!response.isSuccessful) {
return@withContext AliveListResult.Error("网络错误: ${response.code}")
}
if (body.isBlank()) {
return@withContext AliveListResult.Error("服务返回为空")
}
val json = JSONObject(body)
val success = json.optBoolean("success", false)
val message = json.optString("message", json.optString("msg", "")).takeIf { it.isNotBlank() }
val listJson = json.optJSONArray("list")
?: json.optJSONArray("data")
?: JSONArray()
val items = buildList {
for (i in 0 until listJson.length()) {
val item = listJson.optJSONObject(i) ?: continue
add(item.toAliveItem())
}
}
return@withContext AliveListResult.Success(
AliveListResponse(success = success, list = items, message = message)
)
}
} catch (e: Exception) {
return@withContext AliveListResult.Error(e.message ?: "网络请求失败")
}
}
}
private fun JSONObject.toAliveItem(): AliveStreamItem {
val vhost = optString("vhost").ifBlank { optString("host") }.takeIf { it.isNotBlank() }
val app = optString("app").ifBlank { optString("app_name") }.takeIf { it.isNotBlank() }
val stream = optString("stream")
.ifBlank { optString("stream_name") }
.ifBlank { optString("streamId") }
.takeIf { it.isNotBlank() }
val url = optString("url")
.ifBlank { optString("play_url") }
.ifBlank { optString("playUrl") }
.takeIf { it.isNotBlank() }
val previewImage = optString("preview_image")
.ifBlank { optString("preview") }
.ifBlank { optString("cover") }
.takeIf { it.isNotBlank() }
val durationSeconds = when {
has("duration") -> optLong("duration")
has("duration_sec") -> optLong("duration_sec")
else -> null
}?.takeIf { it >= 0 }
val playProtocol = optString("play_protocol")
.ifBlank { optString("protocol") }
.ifBlank { optString("playProtocol") }
.takeIf { it.isNotBlank() }
return AliveStreamItem(
vhost = vhost,
app = app,
stream = stream,
url = url,
previewImage = previewImage,
durationSeconds = durationSeconds,
playProtocol = playProtocol
)
}

View File

@@ -0,0 +1,92 @@
package com.demo.SellyCloudSDK.live.square
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import com.demo.SellyCloudSDK.R
import com.demo.SellyCloudSDK.databinding.ItemLiveSquareCardBinding
import java.util.Locale
class AliveStreamAdapter(
private val onItemClick: (AliveStreamItem) -> Unit
) : RecyclerView.Adapter<AliveStreamAdapter.AliveStreamViewHolder>() {
private val items: MutableList<AliveStreamItem> = mutableListOf()
fun replaceAll(newItems: List<AliveStreamItem>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
fun appendItems(newItems: List<AliveStreamItem>) {
if (newItems.isEmpty()) return
val start = items.size
items.addAll(newItems)
notifyItemRangeInserted(start, newItems.size)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AliveStreamViewHolder {
val binding = ItemLiveSquareCardBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return AliveStreamViewHolder(binding, onItemClick)
}
override fun onBindViewHolder(holder: AliveStreamViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class AliveStreamViewHolder(
private val binding: ItemLiveSquareCardBinding,
private val onItemClick: (AliveStreamItem) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: AliveStreamItem) {
val title = item.stream ?: "-"
binding.tvStreamName.text = title
val protocol = item.playProtocol
?.trim()
?.uppercase(Locale.getDefault())
?.takeIf { it.isNotEmpty() }
?: "-"
binding.tvProtocol.text = protocol
val durationText = item.durationSeconds?.let { formatDuration(it) }
if (durationText == null) {
binding.tvDuration.visibility = View.GONE
} else {
binding.tvDuration.visibility = View.VISIBLE
binding.tvDuration.text = durationText
}
val preview = item.previewImage
binding.ivPreview.load(preview) {
crossfade(true)
placeholder(R.drawable.bg_av_dialog_card_gray)
error(R.drawable.bg_av_dialog_card_gray)
}
binding.root.setOnClickListener { onItemClick(item) }
}
private fun formatDuration(durationSeconds: Long): String {
val total = durationSeconds.coerceAtLeast(0)
val hours = total / 3600
val minutes = (total % 3600) / 60
val seconds = total % 60
return if (hours > 0) {
String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds)
} else {
String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
}
}
}
}

View File

@@ -0,0 +1,58 @@
package com.demo.SellyCloudSDK.live.util
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.provider.MediaStore
import java.io.File
import java.io.FileOutputStream
object GalleryImageSaver {
fun savePng(
context: Context,
bitmap: Bitmap,
filename: String,
relativePath: String = DEFAULT_RELATIVE_PATH
): Boolean {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, relativePath)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false
resolver.openOutputStream(uri)?.use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
} ?: return false
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
true
} else {
val picturesDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_PICTURES)
val targetDir = File(picturesDir, "AVDemo").apply { if (!exists()) mkdirs() }
val file = File(targetDir, filename)
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
val values = ContentValues().apply {
put(MediaStore.Images.Media.DATA, file.absolutePath)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
}
context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
true
}
} catch (_: Exception) {
false
}
}
private const val DEFAULT_RELATIVE_PATH = "Pictures/AVDemo"
}

View File

@@ -0,0 +1,33 @@
package com.demo.SellyCloudSDK.login
import android.content.Context
import androidx.core.content.edit
class DemoLoginStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun isLoggedIn(): Boolean = prefs.getBoolean(KEY_LOGGED_IN, false)
fun readUsername(): String? = prefs.getString(KEY_USERNAME, null)
fun setLoggedIn(username: String) {
prefs.edit {
putBoolean(KEY_LOGGED_IN, true)
putString(KEY_USERNAME, username)
}
}
fun clear() {
prefs.edit {
remove(KEY_LOGGED_IN)
remove(KEY_USERNAME)
}
}
private companion object {
private const val PREF_NAME = "demo_login"
private const val KEY_LOGGED_IN = "logged_in"
private const val KEY_USERNAME = "username"
}
}

View File

@@ -0,0 +1,170 @@
package com.demo.SellyCloudSDK.login
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.InputType
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.demo.SellyCloudSDK.FeatureHubActivity
import com.demo.SellyCloudSDK.R
import com.demo.SellyCloudSDK.databinding.ActivityLoginBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private lateinit var loginStore: DemoLoginStore
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val httpClient = OkHttpClient()
private var isPasswordVisible = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loginStore = DemoLoginStore(this)
if (loginStore.isLoggedIn()) {
navigateToHome()
return
}
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.hide()
binding.etUsername.setText(DEFAULT_USERNAME)
binding.etPassword.setText(DEFAULT_PASSWORD)
setupActions()
}
override fun onDestroy() {
super.onDestroy()
uiScope.cancel()
}
private fun setupActions() {
binding.btnLogin.setOnClickListener { attemptLogin() }
binding.btnTogglePassword.setOnClickListener { togglePasswordVisibility() }
binding.etPassword.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
attemptLogin()
true
} else {
false
}
}
}
private fun attemptLogin() {
val username = binding.etUsername.text?.toString()?.trim().orEmpty()
val password = binding.etPassword.text?.toString()?.trim().orEmpty()
if (username.isEmpty()) {
Toast.makeText(this, getString(R.string.login_invalid_username), Toast.LENGTH_SHORT).show()
return
}
if (password.isEmpty()) {
Toast.makeText(this, getString(R.string.login_invalid_password), Toast.LENGTH_SHORT).show()
return
}
setLoading(true)
uiScope.launch {
val result = requestLogin(username, password)
setLoading(false)
if (result.success) {
loginStore.setLoggedIn(username)
navigateToHome()
} else {
val message = when {
result.statusCode != null -> getString(R.string.login_failed_status, result.statusCode)
else -> getString(R.string.login_failed_network)
}
Toast.makeText(this@LoginActivity, message, Toast.LENGTH_SHORT).show()
}
}
}
private fun togglePasswordVisibility() {
isPasswordVisible = !isPasswordVisible
val selection = binding.etPassword.text?.length ?: 0
binding.etPassword.inputType = if (isPasswordVisible) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
} else {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
binding.etPassword.setSelection(selection)
binding.btnTogglePassword.setImageResource(
if (isPasswordVisible) R.drawable.ic_login_eye else R.drawable.ic_login_eye_off
)
}
private fun setLoading(loading: Boolean) {
binding.btnLogin.isEnabled = !loading
binding.etUsername.isEnabled = !loading
binding.etPassword.isEnabled = !loading
binding.btnTogglePassword.isEnabled = !loading
binding.btnLogin.text = getString(if (loading) R.string.login_loading else R.string.login_action)
}
private suspend fun requestLogin(username: String, password: String): LoginResult {
return withContext(Dispatchers.IO) {
val payload = JSONObject()
.put("username", username)
.put("password", password)
.toString()
val body = payload.toRequestBody(JSON_MEDIA_TYPE)
val request = Request.Builder()
.url(LOGIN_URL)
.post(body)
.build()
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
return@withContext LoginResult(success = true)
}
return@withContext LoginResult(success = false, statusCode = response.code)
}
} catch (e: IOException) {
return@withContext LoginResult(success = false)
}
}
}
private fun navigateToHome() {
startActivity(
Intent(this, FeatureHubActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
)
finish()
}
private data class LoginResult(
val success: Boolean,
val statusCode: Int? = null
)
companion object {
private const val LOGIN_URL = "http://rtmp.sellycloud.io:8089/live/sdk/demo/login"
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private const val DEFAULT_USERNAME = ""
private const val DEFAULT_PASSWORD = ""
fun createIntent(context: Context, clearTask: Boolean = false): Intent {
return Intent(context, LoginActivity::class.java).apply {
if (clearTask) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
}
}
}
}