添加直播模块相关功能,包括登录、播放设置及环境配置管理
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -34,7 +34,7 @@ google-services.json
|
||||
*.hprof
|
||||
|
||||
#sdk files
|
||||
SellyCloudSDK/
|
||||
/SellyCloudSDK/
|
||||
|
||||
|
||||
.gradle-user-home/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
35
example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt
vendored
Normal file
35
example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt
vendored
Normal 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
|
||||
}
|
||||
83
example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvSettingsStore.kt
vendored
Normal file
83
example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvSettingsStore.kt
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user