From 951b473ec806c46492a7dbeea39964eb7c84c5b4 Mon Sep 17 00:00:00 2001 From: shou Date: Mon, 12 Jan 2026 18:08:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9B=B4=E6=92=AD=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E6=8B=AC=E7=99=BB=E5=BD=95=E3=80=81=E6=92=AD=E6=94=BE=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=8F=8A=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- .../avdemo/AvDemoSettingsStore.kt | 66 ++ .../SellyCloudSDK/live/LivePlayActivity.kt | 793 ++++++++++++++++ .../SellyCloudSDK/live/LivePushActivity.kt | 852 ++++++++++++++++++ .../SellyCloudSDK/live/auth/LiveAuthHelper.kt | 52 ++ .../live/auth/LiveTokenSigner.kt | 86 ++ .../live/auth/LiveUrlParamAppender.kt | 62 ++ .../live/env/LiveEnvExtensions.kt | 35 + .../live/env/LiveEnvSettingsStore.kt | 83 ++ .../live/square/AliveListRepository.kt | 107 +++ .../live/square/AliveStreamAdapter.kt | 92 ++ .../live/util/GalleryImageSaver.kt | 58 ++ .../SellyCloudSDK/login/DemoLoginStore.kt | 33 + .../demo/SellyCloudSDK/login/LoginActivity.kt | 170 ++++ 14 files changed, 2490 insertions(+), 1 deletion(-) create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/avdemo/AvDemoSettingsStore.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveAuthHelper.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveTokenSigner.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveUrlParamAppender.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvSettingsStore.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveListRepository.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveStreamAdapter.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/live/util/GalleryImageSaver.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/login/DemoLoginStore.kt create mode 100644 example/src/main/java/com/demo/SellyCloudSDK/login/LoginActivity.kt diff --git a/.gitignore b/.gitignore index df9ffb5..9001511 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,7 @@ google-services.json *.hprof #sdk files -SellyCloudSDK/ +/SellyCloudSDK/ .gradle-user-home/ diff --git a/example/src/main/java/com/demo/SellyCloudSDK/avdemo/AvDemoSettingsStore.kt b/example/src/main/java/com/demo/SellyCloudSDK/avdemo/AvDemoSettingsStore.kt new file mode 100644 index 0000000..8547bf5 --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/avdemo/AvDemoSettingsStore.kt @@ -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 = 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 + } +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt new file mode 100644 index 0000000..bb586b5 --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/LivePlayActivity.kt @@ -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 = 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) + } + } +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt new file mode 100644 index 0000000..cbb189f --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/LivePushActivity.kt @@ -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 { + 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() + } +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveAuthHelper.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveAuthHelper.kt new file mode 100644 index 0000000..553a66d --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveAuthHelper.kt @@ -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, + 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" +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveTokenSigner.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveTokenSigner.kt new file mode 100644 index 0000000..58f76b0 --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveTokenSigner.kt @@ -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 +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveUrlParamAppender.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveUrlParamAppender.kt new file mode 100644 index 0000000..e712b97 --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/auth/LiveUrlParamAppender.kt @@ -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 { + 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 { + 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 { + 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 { + 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 = 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" + } +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt new file mode 100644 index 0000000..4f0a149 --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvExtensions.kt @@ -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 +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvSettingsStore.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvSettingsStore.kt new file mode 100644 index 0000000..bea92db --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/env/LiveEnvSettingsStore.kt @@ -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" + } +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveListRepository.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveListRepository.kt new file mode 100644 index 0000000..8db4754 --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveListRepository.kt @@ -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, + 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 + ) +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveStreamAdapter.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveStreamAdapter.kt new file mode 100644 index 0000000..4b7778f --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/square/AliveStreamAdapter.kt @@ -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() { + + private val items: MutableList = mutableListOf() + + fun replaceAll(newItems: List) { + items.clear() + items.addAll(newItems) + notifyDataSetChanged() + } + + fun appendItems(newItems: List) { + 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) + } + } + } +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/util/GalleryImageSaver.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/util/GalleryImageSaver.kt new file mode 100644 index 0000000..c638a61 --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/live/util/GalleryImageSaver.kt @@ -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" +} + diff --git a/example/src/main/java/com/demo/SellyCloudSDK/login/DemoLoginStore.kt b/example/src/main/java/com/demo/SellyCloudSDK/login/DemoLoginStore.kt new file mode 100644 index 0000000..3b2c83a --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/login/DemoLoginStore.kt @@ -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" + } +} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/login/LoginActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/login/LoginActivity.kt new file mode 100644 index 0000000..e999f52 --- /dev/null +++ b/example/src/main/java/com/demo/SellyCloudSDK/login/LoginActivity.kt @@ -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) + } + } + } + } +}