diff --git a/.gitignore b/.gitignore index fd3f0a0..990e2e4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ google-services.json #sdk files SellyCloudSDK/ + +.gradle-user-home/ +pic/ diff --git a/example/build.gradle b/example/build.gradle index a16a48c..b75979b 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -7,6 +7,8 @@ def sdkGroupId = rootProject.findProperty("sellySdkGroupId") ?: "com.sellycloud" def sdkArtifactId = rootProject.findProperty("sellySdkArtifactId") ?: "sellycloudsdk" def sdkVersion = rootProject.findProperty("sellySdkVersion") ?: "1.0.0" def hasLocalSdk = rootProject.file("SellyCloudSDK").exists() +def releaseStorePath = project.rootProject.file(findProperty("MY_STORE_FILE") ?: "release.keystore") +def hasReleaseKeystore = releaseStorePath != null && releaseStorePath.exists() android { namespace 'com.demo.SellyCloudSDK' @@ -28,17 +30,14 @@ android { signingConfigs { release { - def storePath = project.rootProject.file(findProperty("MY_STORE_FILE") ?: "") - if (storePath != null && storePath.exists()) { - storeFile storePath - } else { - storeFile project.rootProject.file(findProperty("MY_STORE_FILE") ?: "release.keystore") + if (hasReleaseKeystore) { + storeFile releaseStorePath + storePassword findProperty("MY_STORE_PASSWORD") ?: "" + keyAlias findProperty("MY_KEY_ALIAS") ?: "" + keyPassword findProperty("MY_KEY_PASSWORD") ?: "" + v1SigningEnabled true + v2SigningEnabled true } - storePassword findProperty("MY_STORE_PASSWORD") ?: "" - keyAlias findProperty("MY_KEY_ALIAS") ?: "" - keyPassword findProperty("MY_KEY_PASSWORD") ?: "" - v1SigningEnabled true - v2SigningEnabled true } } @@ -47,7 +46,12 @@ android { shrinkResources false minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - signingConfig signingConfigs.release + if (hasReleaseKeystore) { + signingConfig signingConfigs.release + } else { + // Allow local CI/dev builds without a private keystore. + signingConfig signingConfigs.debug + } } } compileOptions { @@ -74,7 +78,6 @@ dependencies { ) implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.github.pedroSG94.RootEncoder:library:2.6.6' - implementation "com.squareup.okhttp3:okhttp:4.12.0" } implementation fileTree( @@ -92,10 +95,18 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.activity:activity-ktx:1.9.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.4' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation "com.squareup.okhttp3:okhttp:4.12.0" + implementation "io.coil-kt:coil:2.6.0" + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:core:1.5.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test:runner:1.5.2' } diff --git a/example/libs/sellycloudsdk-1.0.0.aar b/example/libs/sellycloudsdk-1.0.0.aar index 01ca347..51f2925 100644 Binary files a/example/libs/sellycloudsdk-1.0.0.aar and b/example/libs/sellycloudsdk-1.0.0.aar differ diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index 0b53929..4f96883 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -19,15 +19,19 @@ + android:theme="@style/Theme.AVDemo.NoActionBar" + android:screenOrientation="fullSensor" + android:windowSoftInputMode="adjustResize"> @@ -35,10 +39,26 @@ + + + + - - - = mutableListOf() + private var currentPage = 0 + private var isPaging = false + + private var selectedTab: Tab = Tab.HOME override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + loginStore = DemoLoginStore(this) + if (!loginStore.isLoggedIn()) { + redirectToLogin() + return + } binding = ActivityFeatureHubBinding.inflate(layoutInflater) setContentView(binding.root) - supportActionBar?.title = "SellyCloud SDK DEMO" + supportActionBar?.hide() + settingsStore = AvDemoSettingsStore(this) + envStore = LiveEnvSettingsStore(this) + selectedTab = savedInstanceState?.getString(KEY_SELECTED_TAB) + ?.let { runCatching { Tab.valueOf(it) }.getOrNull() } + ?: Tab.HOME - binding.cardLiveStreaming.setOnClickListener { - startActivity(Intent(this, MainActivity::class.java)) - } + restoreSettingsToUi() + binding.etCallChannelId.setText(getString(R.string.default_call_id)) - binding.cardInteractiveLive.setOnClickListener { - startActivity(Intent(this, InteractiveLiveActivity::class.java)) + setupTabs() + setupActions() + setupSettingsSave() + setupLogout() + setupHomeList() + refreshAliveList() + } + + override fun onStart() { + super.onStart() + if (!loginStore.isLoggedIn() && !isFinishing) { + redirectToLogin() } } + + override fun onDestroy() { + super.onDestroy() + uiScope.cancel() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(KEY_SELECTED_TAB, selectedTab.name) + } + + private fun setupTabs() { + binding.tabHome.setOnClickListener { selectTab(Tab.HOME) } + binding.tabCall.setOnClickListener { selectTab(Tab.CALL) } + binding.tabSettings.setOnClickListener { selectTab(Tab.SETTINGS) } + selectTab(selectedTab) + } + + private fun setupActions() { + binding.btnHomeLivePush.setOnClickListener { + showPushSettingsDialog { showPushProtocolSheet() } + } + binding.btnHomeLivePull.setOnClickListener { + showPlayConfigDialog() + } + binding.btnCallSingleChat.setOnClickListener { + startInteractive(InteractiveLiveActivity.DEFAULT_CALL_TYPE_P2P) + } + binding.btnCallConference.setOnClickListener { + startInteractive(InteractiveLiveActivity.DEFAULT_CALL_TYPE_GROUP) + } + } + + private fun setupHomeList() { + aliveAdapter = AliveStreamAdapter { item -> handleAliveItemClick(item) } + val layoutManager = GridLayoutManager(this, 2) + binding.rvLiveSquare.layoutManager = layoutManager + binding.rvLiveSquare.adapter = aliveAdapter + binding.rvLiveSquare.addItemDecoration(GridSpacingItemDecoration(2, dp(6))) + binding.rvLiveSquare.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy <= 0) return + val lastVisible = layoutManager.findLastVisibleItemPosition() + if (lastVisible >= aliveAdapter.itemCount - 2) { + appendNextPage() + } + } + }) + binding.swipeHome.setOnRefreshListener { refreshAliveList() } + } + + private fun refreshAliveList() { + binding.swipeHome.isRefreshing = true + uiScope.launch { + when (val result = AliveListRepository.fetchAliveList()) { + is AliveListResult.Success -> { + binding.swipeHome.isRefreshing = false + val response = result.response + if (!response.success) { + showHomeError(response.message ?: getString(R.string.home_live_square_error)) + return@launch + } + resetPaging(response.list) + } + is AliveListResult.Error -> { + binding.swipeHome.isRefreshing = false + showHomeError(result.message) + } + } + } + } + + private fun resetPaging(items: List) { + allAliveItems.clear() + allAliveItems.addAll(items) + currentPage = 0 + aliveAdapter.replaceAll(emptyList()) + appendNextPage() + updateHomeEmptyState() + } + + private fun appendNextPage() { + if (isPaging) return + val start = currentPage * PAGE_SIZE + if (start >= allAliveItems.size) return + isPaging = true + val end = min(start + PAGE_SIZE, allAliveItems.size) + aliveAdapter.appendItems(allAliveItems.subList(start, end)) + currentPage += 1 + isPaging = false + } + + private fun updateHomeEmptyState() { + val empty = allAliveItems.isEmpty() + binding.tvHomeEmpty.text = getString(R.string.home_live_square_empty) + binding.tvHomeEmpty.visibility = if (empty) View.VISIBLE else View.GONE + binding.rvLiveSquare.visibility = if (empty) View.INVISIBLE else View.VISIBLE + } + + private fun showHomeError(message: String) { + allAliveItems.clear() + aliveAdapter.replaceAll(emptyList()) + binding.tvHomeEmpty.text = message + binding.tvHomeEmpty.visibility = View.VISIBLE + binding.rvLiveSquare.visibility = View.INVISIBLE + } + + private fun handleAliveItemClick(item: AliveStreamItem) { + val liveMode = resolvePlayMode(item.playProtocol) + val params = buildPlayParams(item) + if (params == null) { + Toast.makeText(this, "播放地址缺失", Toast.LENGTH_SHORT).show() + return + } + startActivity( + LivePlayActivity.createIntentWithParams( + this, + liveMode, + params.vhost, + params.appName, + params.streamName, + autoStart = true + ) + ) + } + + private fun resolvePlayMode(raw: String?): SellyLiveMode { + val normalized = raw?.trim()?.uppercase() ?: "" + return if (normalized == "WHEP" || normalized == "WHIP" || normalized == "RTC") { + SellyLiveMode.RTC + } else { + SellyLiveMode.RTMP + } + } + + private fun buildPlayParams(item: AliveStreamItem): PlayParams? { + val env = envStore.read() + val vhost = item.vhost?.trim().orEmpty().ifBlank { env.normalizedVhost() } + val app = item.app?.trim().orEmpty().ifBlank { env.normalizedAppName() } + val stream = item.stream?.trim().orEmpty() + if (vhost.isBlank() || app.isBlank() || stream.isBlank()) return null + return PlayParams(vhost = vhost, appName = app, streamName = stream) + } + + private data class PlayParams( + val vhost: String, + val appName: String, + val streamName: String + ) + + private fun startInteractive(defaultCallType: String) { + val callId = binding.etCallChannelId.text?.toString()?.trim().orEmpty() + if (callId.isEmpty()) { + Toast.makeText(this, getString(R.string.call_id_required), Toast.LENGTH_SHORT).show() + return + } + startActivity( + Intent(this, InteractiveLiveActivity::class.java) + .putExtra(InteractiveLiveActivity.EXTRA_DEFAULT_CALL_TYPE, defaultCallType) + .putExtra(InteractiveLiveActivity.EXTRA_CALL_ID, callId) + ) + } + + private fun showPushProtocolSheet() { + val dialog = Dialog(this, R.style.Theme_AVDemo_Dialog_BottomSheet) + val sheetBinding = DialogPushProtocolSheetBinding.inflate(layoutInflater) + dialog.setContentView(sheetBinding.root) + dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + dialog.window?.setGravity(Gravity.BOTTOM) + + sheetBinding.btnRtc.setOnClickListener { + dialog.dismiss() + startActivity(LivePushActivity.createIntent(this, SellyLiveMode.RTC)) + } + sheetBinding.btnRtmp.setOnClickListener { + dialog.dismiss() + startActivity(LivePushActivity.createIntent(this, SellyLiveMode.RTMP)) + } + sheetBinding.btnCancel.setOnClickListener { dialog.dismiss() } + + dialog.show() + } + + private fun showPushSettingsDialog(onApplied: () -> Unit) { + val dialog = Dialog(this) + val dialogBinding = DialogLivePresetSettingsBinding.inflate(layoutInflater) + dialog.setContentView(dialogBinding.root) + dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT)) + dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + dialog.window?.decorView?.setPadding(0, 0, 0, 0) + + val current = settingsStore.read() + dialogBinding.etStreamId.setText(generateRandomStreamId()) + dialogBinding.etFps.setText(current.fps.toString()) + dialogBinding.etMaxBitrate.setText(current.maxBitrateKbps.toString()) + dialogBinding.etMinBitrate.setText(current.minBitrateKbps.toString()) + dialogBinding.rgResolution.check( + when (current.resolution) { + AvDemoSettings.Resolution.P360 -> R.id.rbRes360p + AvDemoSettings.Resolution.P480 -> R.id.rbRes480p + AvDemoSettings.Resolution.P540 -> R.id.rbRes540p + AvDemoSettings.Resolution.P720 -> 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() + + 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) { + R.id.rbRes360p -> AvDemoSettings.Resolution.P360 + R.id.rbRes480p -> AvDemoSettings.Resolution.P480 + 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) + dialog.dismiss() + onApplied() + } + + dialog.show() + } + + private fun showPlayConfigDialog() { + val dialog = Dialog(this, R.style.Theme_AVDemo_Dialog_FullscreenOverlay) + val dialogBinding = DialogPlayConfigOverlayBinding.inflate(layoutInflater) + dialog.setContentView(dialogBinding.root) + dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + dialog.setCancelable(true) + + dialogBinding.btnClose.setOnClickListener { dialog.dismiss() } + dialogBinding.btnStartPlay.setOnClickListener { + val input = dialogBinding.etStreamIdOrUrl.text?.toString()?.trim().orEmpty() + if (input.isEmpty()) { + Toast.makeText(this, "请输入 Stream ID 或 URL", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + val liveMode = if (dialogBinding.rbRtc.isChecked) { + SellyLiveMode.RTC + } else { + SellyLiveMode.RTMP + } + dialog.dismiss() + startActivity(LivePlayActivity.createIntent(this, liveMode, input, autoStart = true)) + } + + dialog.show() + } + + private fun setupSettingsSave() { + binding.btnSaveSettings.setOnClickListener { + val settings = uiToSettingsOrNull() ?: return@setOnClickListener + settingsStore.write(settings) + val envSettings = uiToEnvSettings(envStore.read(), settings.streamId) + envStore.write(envSettings) + Toast.makeText(this, "已保存", Toast.LENGTH_SHORT).show() + } + } + + private fun setupLogout() { + binding.btnLogout.setOnClickListener { + loginStore.clear() + redirectToLogin() + } + } + + private fun redirectToLogin() { + startActivity(LoginActivity.createIntent(this, clearTask = true)) + finish() + } + + private fun restoreSettingsToUi() { + val settings = settingsStore.read() + binding.etSettingsStreamId.setText(settings.streamId) + binding.etSettingsFps.setText(settings.fps.toString()) + binding.etSettingsMaxBitrate.setText(settings.maxBitrateKbps.toString()) + binding.etSettingsMinBitrate.setText(settings.minBitrateKbps.toString()) + when (settings.resolution) { + AvDemoSettings.Resolution.P360 -> binding.rgSettingsResolution.check(R.id.rbSettingsRes360p) + AvDemoSettings.Resolution.P480 -> binding.rgSettingsResolution.check(R.id.rbSettingsRes480p) + AvDemoSettings.Resolution.P540 -> binding.rgSettingsResolution.check(R.id.rbSettingsRes540p) + AvDemoSettings.Resolution.P720 -> binding.rgSettingsResolution.check(R.id.rbSettingsRes720p) + } + restoreEnvSettingsToUi() + } + + private fun restoreEnvSettingsToUi() { + val env = envStore.read() + binding.etSettingsVhost.setText(env.vhost) + binding.etSettingsVhostKey.setText(env.vhostKey) + binding.etSettingsAppId.setText(env.appId) + } + + private fun uiToSettingsOrNull(): AvDemoSettings? { + val streamId = binding.etSettingsStreamId.text?.toString()?.trim().orEmpty() + val fps = binding.etSettingsFps.text?.toString()?.trim()?.toIntOrNull() + val maxKbps = binding.etSettingsMaxBitrate.text?.toString()?.trim()?.toIntOrNull() + val minKbps = binding.etSettingsMinBitrate.text?.toString()?.trim()?.toIntOrNull() + + if (streamId.isEmpty()) { + Toast.makeText(this, "请输入 Stream ID", Toast.LENGTH_SHORT).show() + return null + } + if (fps == null || fps <= 0) { + Toast.makeText(this, "请输入正确的 FPS", Toast.LENGTH_SHORT).show() + return null + } + if (maxKbps == null || maxKbps <= 0) { + Toast.makeText(this, "请输入正确的最大码率", Toast.LENGTH_SHORT).show() + return null + } + if (minKbps == null || minKbps <= 0) { + Toast.makeText(this, "请输入正确的最小码率", Toast.LENGTH_SHORT).show() + return null + } + + val res = when (binding.rgSettingsResolution.checkedRadioButtonId) { + R.id.rbSettingsRes360p -> AvDemoSettings.Resolution.P360 + R.id.rbSettingsRes480p -> AvDemoSettings.Resolution.P480 + R.id.rbSettingsRes540p -> AvDemoSettings.Resolution.P540 + else -> AvDemoSettings.Resolution.P720 + } + val current = settingsStore.read() + return current.copy( + streamId = streamId, + resolution = res, + fps = fps, + maxBitrateKbps = maxKbps, + minBitrateKbps = minKbps + ) + } + + private fun uiToEnvSettings(current: LiveEnvSettings, streamId: String): LiveEnvSettings { + val vhost = binding.etSettingsVhost.text?.toString()?.trim().orEmpty() + val vhostKey = binding.etSettingsVhostKey.text?.toString()?.trim().orEmpty() + val appId = binding.etSettingsAppId.text?.toString()?.trim().orEmpty() + return current.copy( + vhost = vhost, + vhostKey = vhostKey, + appId = appId, + defaultStreamId = streamId + ) + } + + private fun selectTab(tab: Tab) { + selectedTab = tab + binding.tabHome.isSelected = tab == Tab.HOME + binding.tabCall.isSelected = tab == Tab.CALL + binding.tabSettings.isSelected = tab == Tab.SETTINGS + + binding.pageHome.visibility = if (tab == Tab.HOME) View.VISIBLE else View.GONE + binding.pageCall.visibility = if (tab == Tab.CALL) View.VISIBLE else View.GONE + binding.pageSettings.visibility = if (tab == Tab.SETTINGS) View.VISIBLE else View.GONE + + val titleRes = when (tab) { + Tab.HOME -> R.string.tab_home + Tab.CALL -> R.string.tab_call + Tab.SETTINGS -> R.string.tab_settings + } + binding.tvTitle.setText(titleRes) + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() + + private fun generateRandomStreamId(): String = Random.nextInt(100, 1000).toString() + + private class GridSpacingItemDecoration( + private val spanCount: Int, + private val spacing: Int + ) : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) + if (position == RecyclerView.NO_POSITION) return + val column = position % spanCount + outRect.left = spacing - column * spacing / spanCount + outRect.right = (column + 1) * spacing / spanCount + outRect.top = if (position < spanCount) spacing else spacing / 2 + outRect.bottom = spacing + } + } + + private enum class Tab { HOME, CALL, SETTINGS } + + private companion object { + private const val KEY_SELECTED_TAB = "selected_tab" + private const val PAGE_SIZE = 4 + } } diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FUBeautyFilterRender.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FUBeautyFilterRender.kt index 19755fe..4940a36 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FUBeautyFilterRender.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FUBeautyFilterRender.kt @@ -67,11 +67,17 @@ class FUBeautyFilterRender( previewWidth: Int, previewHeight: Int ) { + // GL 上下文可能重建:确保滤镜和 FaceUnity 资源重新初始化 + isInitialized = false + program = -1 + // 先保存 ApplicationContext,避免 super.initGl 内部触发 initGlFilter 时为空 + this.appContext = context.applicationContext super.initGl(width, height, context, previewWidth, previewHeight) // 确保使用 ApplicationContext,避免Activity依赖 - this.appContext = context.applicationContext frameW = width frameH = height + // 刷新 FaceUnity GL 资源绑定到新的上下文 + fuRenderer.reinitializeGlContextBlocking() Log.d(TAG, "initGl: width=$width, height=$height, context=${context.javaClass.simpleName}") } @@ -234,6 +240,7 @@ class FUBeautyFilterRender( } override fun release() { + isInitialized = false if (program != -1) { GLES20.glDeleteProgram(program) program = -1 diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FURenderer.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FURenderer.kt index 669fa99..1f89ec2 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FURenderer.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FURenderer.kt @@ -19,7 +19,9 @@ import com.faceunity.wrapper.faceunity import com.pedro.encoder.input.video.CameraHelper import java.io.File import java.io.IOException +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit /** @@ -50,7 +52,11 @@ class FURenderer(private val context: Context) { private val BUNDLE_AI_HUMAN = "model" + File.separator + "ai_human_processor.bundle" private val BUNDLE_FACE_BEAUTY = "graphics" + File.separator + "face_beautification.bundle" - private val workerThread = Executors.newSingleThreadExecutor() + @Volatile + private var workerThreadRef: Thread? = null + private val workerThread = Executors.newSingleThreadExecutor { task -> + Thread(task, "FURenderer-Worker").also { workerThreadRef = it } + } // 添加摄像头朝向管理 private var currentCameraFacing: CameraHelper.Facing = CameraHelper.Facing.BACK @@ -155,11 +161,6 @@ class FURenderer(private val context: Context) { // 检查 SDK 和 GL 是否就绪 if (!isAuthSuccess || !isGlInitialized || fuRenderKit == null) { - // 如果认证成功但 GL 未初始化,尝试初始化 - if (isAuthSuccess && !isGlInitialized) { - Log.w(TAG, "GL not initialized, attempting to initialize") - reinitializeGlContext() - } return inputTex } @@ -255,30 +256,58 @@ class FURenderer(private val context: Context) { */ fun reinitializeGlContext() { if (!isAuthSuccess) return + workerThread.execute { doReinitializeGlContext() } + } + /** + * 重新初始化 GL 上下文(同步等待完成,用于避免美颜空窗) + */ + fun reinitializeGlContextBlocking(timeoutMs: Long = 2000L) { + if (!isAuthSuccess) return + if (Thread.currentThread() === workerThreadRef) { + doReinitializeGlContext() + return + } + val latch = CountDownLatch(1) workerThread.execute { try { - Log.d(TAG, "Reinitializing GL context after protocol switch") - - // 重新获取 FURenderKit 实例(绑定到新的 GL 上下文) - fuRenderKit = FURenderKit.getInstance() - - // 重新设置异步纹理模式 - faceunity.fuSetUseTexAsync(1) - - // 如果之前有美颜配置,重新应用 - if (faceBeauty != null) { - fuRenderKit?.faceBeauty = faceBeauty - Log.d(TAG, "Beauty configuration reapplied") - } - - isGlInitialized = true - Log.d(TAG, "GL context reinitialized successfully") - } catch (e: Exception) { - Log.e(TAG, "Error reinitializing GL context", e) - isGlInitialized = false + doReinitializeGlContext() + } finally { + latch.countDown() } } + try { + if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) { + Log.w(TAG, "GL context reinit timeout: ${timeoutMs}ms") + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + Log.w(TAG, "GL context reinit interrupted") + } + } + + private fun doReinitializeGlContext() { + try { + Log.d(TAG, "Reinitializing GL context after protocol switch") + + // 重新获取 FURenderKit 实例(绑定到新的 GL 上下文) + fuRenderKit = FURenderKit.getInstance() + + // 重新设置异步纹理模式 + faceunity.fuSetUseTexAsync(1) + + // 如果之前有美颜配置,重新应用 + if (faceBeauty != null) { + fuRenderKit?.faceBeauty = faceBeauty + Log.d(TAG, "Beauty configuration reapplied") + } + + isGlInitialized = true + Log.d(TAG, "GL context reinitialized successfully") + } catch (e: Exception) { + Log.e(TAG, "Error reinitializing GL context", e) + isGlInitialized = false + } } /** diff --git a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt index a6b8f7b..ec4f944 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/beauty/FuVideoFrameInterceptor.kt @@ -1,12 +1,12 @@ package com.demo.SellyCloudSDK.beauty import android.util.Log -import com.sellycloud.sellycloudsdk.VideoFrameInterceptor import com.faceunity.core.entity.FURenderInputData import com.faceunity.core.enumeration.CameraFacingEnum import com.faceunity.core.enumeration.FUExternalInputEnum import com.faceunity.core.enumeration.FUInputBufferEnum import com.faceunity.core.enumeration.FUTransformMatrixEnum +import com.sellycloud.sellycloudsdk.VideoFrameInterceptor import org.webrtc.JavaI420Buffer import org.webrtc.VideoFrame diff --git a/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveForegroundService.kt b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveForegroundService.kt index e32cf3a..651984b 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveForegroundService.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveForegroundService.kt @@ -8,8 +8,8 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat import com.demo.SellyCloudSDK.R /** diff --git a/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt index 2064a73..776ab03 100644 --- a/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt +++ b/example/src/main/java/com/demo/SellyCloudSDK/interactive/InteractiveLiveActivity.kt @@ -24,8 +24,8 @@ import com.sellycloud.sellycloudsdk.interactive.InteractiveChannelMediaOptions import com.sellycloud.sellycloudsdk.interactive.InteractiveConnectionState import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngine import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngine.ConnectionReason -import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngineEventHandler import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngineConfig +import com.sellycloud.sellycloudsdk.interactive.InteractiveRtcEngineEventHandler import com.sellycloud.sellycloudsdk.interactive.InteractiveStreamStats import com.sellycloud.sellycloudsdk.interactive.InteractiveVideoCanvas import com.sellycloud.sellycloudsdk.interactive.InteractiveVideoEncoderConfig @@ -37,6 +37,7 @@ class InteractiveLiveActivity : AppCompatActivity() { private lateinit var binding: ActivityInteractiveLiveBinding private var rtcEngine: InteractiveRtcEngine? = null + private var lockedCallType: CallType? = null private var localRenderer: SurfaceViewRenderer? = null private lateinit var localSlot: VideoSlot private lateinit var remoteSlots: List @@ -106,6 +107,7 @@ class InteractiveLiveActivity : AppCompatActivity() { setupVideoSlots() initRtcEngine() setupUiDefaults() + applyDefaultCallTypeFromIntent() setupControlButtons() binding.btnJoin.setOnClickListener { @@ -128,6 +130,24 @@ class InteractiveLiveActivity : AppCompatActivity() { } } + private fun applyDefaultCallTypeFromIntent() { + lockedCallType = when (intent.getStringExtra(EXTRA_DEFAULT_CALL_TYPE)) { + DEFAULT_CALL_TYPE_GROUP -> CallType.GROUP + DEFAULT_CALL_TYPE_P2P -> CallType.ONE_TO_ONE + else -> null + } + + when (lockedCallType) { + CallType.GROUP -> binding.rbCallTypeGroup.isChecked = true + CallType.ONE_TO_ONE -> binding.rbCallTypeP2p.isChecked = true + null -> Unit + } + + // Home 已经确定单聊/多方通话时,这里不再让用户二次选择。 + val locked = lockedCallType != null + binding.callTypeGroup.isVisible = !locked + } + override fun onDestroy() { super.onDestroy() rtcEngine?.setCaptureVideoFrameInterceptor(null) @@ -151,6 +171,7 @@ class InteractiveLiveActivity : AppCompatActivity() { private fun initRtcEngine() { val appId = getString(R.string.signaling_app_id) val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() } + val kiwiRsName = getString(R.string.signaling_kiwi_rsname).trim() beautyRenderer = FURenderer(this).also { it.setup() } fuFrameInterceptor = beautyRenderer?.let { FuVideoFrameInterceptor(it).apply { setFrontCamera(isFrontCamera) @@ -160,12 +181,13 @@ class InteractiveLiveActivity : AppCompatActivity() { InteractiveRtcEngineConfig( context = applicationContext, appId = appId, - defaultToken = token + defaultToken = token, + kiwiRsName = kiwiRsName ) ).apply { setEventHandler(rtcEventHandler) setClientRole(InteractiveRtcEngine.ClientRole.BROADCASTER) -// setVideoEncoderConfiguration(InteractiveVideoEncoderConfig()) 使用默认值 +// setVideoEncoderConfiguration(InteractiveVideoEncoderConfig()) 使用默认值 setVideoEncoderConfiguration(InteractiveVideoEncoderConfig(640, 480 , fps = 20, minBitrateKbps = 150, maxBitrateKbps = 850)) setDefaultAudioRoutetoSpeakerphone(true) setCaptureVideoFrameInterceptor { frame -> @@ -318,7 +340,12 @@ class InteractiveLiveActivity : AppCompatActivity() { } private fun setupUiDefaults() { - binding.etCallId.setText(getString(R.string.default_call_id)) + val presetCallId = intent.getStringExtra(EXTRA_CALL_ID) + if (!presetCallId.isNullOrBlank()) { + binding.etCallId.setText(presetCallId) + } else { + binding.etCallId.setText(getString(R.string.default_call_id)) + } val defaultUser = String.format( getString(R.string.default_user_id), System.currentTimeMillis().toString().takeLast(4) @@ -494,9 +521,9 @@ class InteractiveLiveActivity : AppCompatActivity() { Toast.makeText(this, R.string.signaling_app_id_missing, Toast.LENGTH_LONG).show() return } - val options = InteractiveChannelMediaOptions( - callType = if (binding.rbCallTypeP2p.isChecked) CallType.ONE_TO_ONE else CallType.GROUP - ) + val options = InteractiveChannelMediaOptions(callType = lockedCallType ?: run { + if (binding.rbCallTypeP2p.isChecked) CallType.ONE_TO_ONE else CallType.GROUP + }) val tokenBundle = buildToken(appId, callId, userInput) ?: return pendingJoinRequest = JoinRequest( token = tokenBundle.token, @@ -851,6 +878,11 @@ class InteractiveLiveActivity : AppCompatActivity() { } companion object { + const val EXTRA_DEFAULT_CALL_TYPE = "default_call_type" + const val EXTRA_CALL_ID = "call_id" + const val DEFAULT_CALL_TYPE_P2P = "p2p" + const val DEFAULT_CALL_TYPE_GROUP = "group" + private const val TAG = "InteractiveLiveActivity" } diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/MainActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/MainActivity.kt deleted file mode 100644 index 8c692f4..0000000 --- a/example/src/main/java/com/demo/SellyCloudSDK/live/MainActivity.kt +++ /dev/null @@ -1,887 +0,0 @@ -package com.demo.SellyCloudSDK.live - -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.PixelFormat -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.SurfaceHolder -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.core.content.ContextCompat -import com.demo.SellyCloudSDK.R -import com.demo.SellyCloudSDK.beauty.FaceUnityBeautyEngine -import com.demo.SellyCloudSDK.databinding.ActivityMainBinding -import com.sellycloud.sellycloudsdk.* -import com.sellycloud.sellycloudsdk.PlayerConfig -import com.sellycloud.sellycloudsdk.RtmpPlayer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.delay -import org.webrtc.SurfaceViewRenderer -import com.sellycloud.sellycloudsdk.Protocol -import android.content.res.Configuration -import org.webrtc.RendererCommon -import kotlin.text.clear - -class MainActivity : AppCompatActivity(), - SurfaceHolder.Callback { - - private lateinit var binding: ActivityMainBinding - - // 单一 StreamingManager,按协议初始化 - private var streamingManager: StreamingManager? = null - private val faceUnityBeautyEngine: FaceUnityBeautyEngine by lazy { FaceUnityBeautyEngine() } - - // UI 状态助手 - private lateinit var uiState: UiStateManager - - // 播放 Surface 管理器 - private lateinit var playSurfaceManager: PlaySurfaceManager - - // WHEP 相关 - private var whepClient: WhepClient? = null - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private var isWhepPlaying = false - private var whepSurfaceView: SurfaceViewRenderer? = null - private var webrtcEglBase: org.webrtc.EglBase? = null - - // 预览 Surface 就绪标志(RTMP 预览视图) - private var isPushSurfaceReady = false - - // 协议选择 - private var selectedProtocol: Protocol = Protocol.RTMP - - // 播放类型枚举 - private enum class PlayType { NONE, RTMP, WHEP } - private var currentPlayType = PlayType.NONE - - // 播放器 - private var player: RtmpPlayer? = null - private var playerConfig: PlayerConfig? = null - private var isPlaySurfaceValid = false - private var lastPlayUrl: String? = null - private var shouldResumePlayback = false - private var needRecreatePlayer = false - - // 状态变量 - private var idelStatus = "待启动" - - private val permissions = - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) arrayOf( - Manifest.permission.CAMERA, - Manifest.permission.RECORD_AUDIO, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) else arrayOf( - Manifest.permission.CAMERA, - Manifest.permission.RECORD_AUDIO - ) - - // 防止重复启动预览导致多次 GL / 美颜初始化 - private var hasStartedPushPreview = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - // 初始化 UI 与管理器 - uiState = UiStateManager(binding) - playSurfaceManager = PlaySurfaceManager(binding.surfaceViewPlay) - uiState.setRtmpButtonText(false) - updateWhepButtonText() - - // 屏幕常亮 - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - - // 初始化 StreamingManager 与监听 - streamingManager = StreamingManager(this).also { mgr -> - mgr.setBeautyEngine(faceUnityBeautyEngine) - mgr.setStreamingListener(object : StreamingListener { - override fun onStateUpdate(state: StreamingState, message: String?, extras: Bundle?) { - runOnUiThread { - val text = message ?: when (state) { - StreamingState.IDLE -> "待启动" - StreamingState.CONNECTING -> "连接中..." - StreamingState.STREAMING -> "推流中" - StreamingState.RECONNECTING -> "重连中..." - StreamingState.STOPPED -> "已停止" - StreamingState.FAILED -> "推流错误" - } - val logMap = mapOf( - "state" to state.name, - "message" to message, - "extras" to bundleToMap(extras) - ) - Log.d("MainActivity111111", logMap.toString()) - uiState.setPushStatusText(text, idelStatus) - uiState.setPushButtonsEnabled(state == StreamingState.STREAMING) - setProtocolSelectionEnabled(state != StreamingState.STREAMING && state != StreamingState.CONNECTING && state != StreamingState.RECONNECTING) - } - } - override fun onError(error: StreamingError) { - runOnUiThread { Toast.makeText(this@MainActivity, error.message, Toast.LENGTH_SHORT).show() } - } - }) - } - // 默认 RTMP 预览标题 - val defaultId = if (selectedProtocol == Protocol.WHIP) R.id.rbProtocolWhip else R.id.rbProtocolRtmp - if (binding.protocolGroup.checkedRadioButtonId != defaultId) { - binding.protocolGroup.check(defaultId) - } - setPushPreviewHeader(selectedProtocol.name) - // 绑定 UI 监听 - setupListeners() - - // 权限 - checkAndRequestPermissions() - } - - // 接管屏幕方向变化,避免 Activity 重建导致两个预览销毁 - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - - // RTMP 推流预览(OpenGlView)保持像素格式与叠放层不变,仅请求重新布局 - try { - binding.surfaceViewPlay.setZOrderMediaOverlay(false) - binding.surfaceViewPlay.holder.setFormat(PixelFormat.OPAQUE) - binding.surfaceViewPlay.requestLayout() - } catch (_: Exception) {} - - // WHIP 推流预览(SurfaceViewRenderer)只调整缩放并请求布局 - try { - binding.whipPreview.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) - binding.whipPreview.setEnableHardwareScaler(true) - binding.whipPreview.requestLayout() - } catch (_: Exception) {} - - // 若当前是 WHEP 播放,动态渲染器同样更新缩放并请求布局 - try { - whepSurfaceView?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) - whepSurfaceView?.setEnableHardwareScaler(true) - whepSurfaceView?.requestLayout() - } catch (_: Exception) {} - - // 播放 Surface 保持 RGBA_8888 与覆盖层,确保颜色/叠放正确 - try { - binding.surfaceViewPlay.setZOrderMediaOverlay(true) - ensurePlaySurfaceFormat() - binding.surfaceViewPlay.requestLayout() - } catch (_: Exception) {} - } - - override fun onResume() { - super.onResume() - // 恢复美颜/GL 管线 -// streamingManager?.onResume() - // 恢复预览(RTMP/WHIP) - if (isPushSurfaceReady) { - try { - streamingManager?.resumePreview() - } catch (_: Exception) { - } - } - // 播放器复位 - if (needRecreatePlayer && isPlaySurfaceValid) { - recreatePlayerAndMaybeResume() - } else if (shouldResumePlayback && !lastPlayUrl.isNullOrEmpty()) { - val holder = binding.surfaceViewPlay.holder - if (holder.surface != null && holder.surface.isValid) { - ensurePlaySurfaceFormat() - player?.setSurface(holder.surface) - player?.prepareAsync(lastPlayUrl!!) - updateStatus(playStatus = "正在连接") - updatePlayButtonStates(false) - shouldResumePlayback = false - } - } - } - - override fun onPause() { - super.onPause() - // 暂停美颜/GL 管线 -// streamingManager?.onPause() - // 暂停预览(RTMP/WHIP) - if (selectedProtocol == Protocol.RTMP) { - try { streamingManager?.pausePreview() } catch (_: Exception) {} - } else if (selectedProtocol == Protocol.WHIP) { - try { streamingManager?.stopWhipPreview() } catch (_: Exception) {} - } - // 播放侧资源处理 - shouldResumePlayback = player?.isPlaying() == true || player?.isPrepared() == true - try { player?.setSurface(null) } catch (_: Exception) {} - player?.release(); player = null - needRecreatePlayer = true - shouldResumePlayback = true - } - - private fun setupListeners() { - // 开始推流(根据协议) - binding.btnStartPush.setOnClickListener { - // 获取各个配置字段 -// val host = binding.etHost.text.toString().trim() - val appName = binding.etAppName.text.toString().trim() - val streamName = binding.etStreamName.text.toString().trim() -// val streamKey = binding.etStreamKey.text.toString().trim() - - // 验证必填字段 -// if (host.isEmpty()) { -// Toast.makeText(this, "请输入Host地址", Toast.LENGTH_SHORT).show() -// return@setOnClickListener -// } - if (appName.isEmpty()) { - Toast.makeText(this, "请输入App Name", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - if (streamName.isEmpty()) { - Toast.makeText(this, "请输入Stream Name", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - - streamingManager?.updateStreamConfig( - host = "rtmp.sellycloud.push", - appName = appName, - streamName = streamName, - streamKey = "" - ) - streamingManager?.startStreaming() - // 同步美颜 - streamingManager?.setBeautyEnabled(binding.switchBeauty.isChecked) - } - // 停止推流 - binding.btnStopPush.setOnClickListener { streamingManager?.stopStreaming() } - // 协议选择监听 - binding.protocolGroup.setOnCheckedChangeListener { _, checkedId -> - val newProtocol = if (checkedId == R.id.rbProtocolWhip) Protocol.WHIP else Protocol.RTMP - if (newProtocol != selectedProtocol) { - switchProtocol(newProtocol) - } - } - // 切换摄像头 - binding.btnSwitchCamera.setOnClickListener { streamingManager?.switchCamera() } - // 切换方向 - binding.btnSwitchOrientation.setOnClickListener { streamingManager?.switchOrientation() } - // 镜像 - binding.cbPreviewHFlip.setOnCheckedChangeListener { _, h -> - streamingManager?.setMirror(horizontal = h, vertical = binding.cbPreviewVFlip.isChecked) - } - binding.cbPreviewVFlip.setOnCheckedChangeListener { _, v -> - streamingManager?.setMirror(horizontal = binding.cbPreviewHFlip.isChecked, vertical = v) - } - // 美颜 - binding.switchBeauty.setOnCheckedChangeListener { _, on -> - streamingManager?.setBeautyEnabled(on) - Toast.makeText(this, "美颜功能${if (on) "开启" else "关闭"}", Toast.LENGTH_SHORT).show() - } - binding.switchBeauty.setOnLongClickListener { Toast.makeText(this, "高级美颜面板暂未开放", Toast.LENGTH_SHORT).show(); true } - // 分辨率 - binding.resolutionGroup.setOnCheckedChangeListener { _, checkedId -> - val (w, h) = when (checkedId) { - R.id.res360p -> 360 to 640 - R.id.res540p -> 540 to 960 - R.id.res720p -> 720 to 1280 - R.id.res1080p -> 1080 to 1920 - else -> 720 to 1280 - } - streamingManager?.changeResolution(w, h) - } - // 选择图片作为视频源 - val pickImage = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> - if (uri == null) { Toast.makeText(this, "未选择图片", Toast.LENGTH_SHORT).show(); return@registerForActivityResult } - try { - val bmp = decodeBitmapFromUri(uri) - if (bmp != null) { - val ok = streamingManager?.setBitmapAsVideoSource(bmp) - Toast.makeText(this, if (ok == true) "已切换为图片源" else "暂不支持该分辨率/失败", Toast.LENGTH_SHORT).show() - } else Toast.makeText(this, "图片解码失败", Toast.LENGTH_SHORT).show() - } catch (e: Exception) { Toast.makeText(this, "设置图片源失败: ${e.message}", Toast.LENGTH_LONG).show() } - } - binding.btnChooseImageSource.setOnClickListener { pickImage.launch("image/*") } - // 恢复摄像头视频源 - binding.btnRestoreCamera.setOnClickListener { streamingManager?.restoreCameraVideoSource() } - - // RTMP 播放:单按钮切换 开始/停止 - binding.btnPlay.setOnClickListener { - if (currentPlayType == PlayType.RTMP) { - stopCurrentPlayback() - return@setOnClickListener - } - val playAppName = binding.etPlayAppName.text.toString().trim() - val playStreamName = binding.etPlayStreamName.text.toString().trim() - - if (playAppName.isEmpty()) { Toast.makeText(this, "请输入Play App Name", Toast.LENGTH_SHORT).show(); return@setOnClickListener } - if (playStreamName.isEmpty()) { Toast.makeText(this, "请输入Play Stream Name", Toast.LENGTH_SHORT).show(); return@setOnClickListener } - - val url = buildPlayUrl("RTMP", playAppName, playStreamName) - Toast.makeText(this, "播放地址: $url", Toast.LENGTH_SHORT).show() - - stopCurrentPlayback() // 停止其他播放(如WHEP) - if (isPlaySurfaceValid) { - ensurePlaySurfaceFormat() - lastPlayUrl = url - currentPlayType = PlayType.RTMP - player?.setSurface(binding.surfaceViewPlay.holder.surface) - player?.prepareAsync(url) - updatePlayButtonStates(false) - uiState.setRtmpButtonText(true) - updateStatus(playStatus = "正在连接(RTMP)") - } else Toast.makeText(this, "播放 Surface 未准备好", Toast.LENGTH_SHORT).show() - } - - // 已移除独立的停止播放按钮 - // WHEP 拉流:单按钮切换 开始/停止 - binding.btnWhepPlay.setOnClickListener { - if (isWhepPlaying) { - stopWhepStreaming() - } else { - val playAppName = binding.etPlayAppName.text.toString().trim() - val playStreamName = binding.etPlayStreamName.text.toString().trim() - if (playAppName.isEmpty()) { Toast.makeText(this, "请输入Play App Name", Toast.LENGTH_SHORT).show(); return@setOnClickListener } - if (playStreamName.isEmpty()) { Toast.makeText(this, "请输入Play Stream Name", Toast.LENGTH_SHORT).show(); return@setOnClickListener } - val url = buildPlayUrl("WHEP", playAppName, playStreamName) - Toast.makeText(this, "播放地址: $url", Toast.LENGTH_SHORT).show() - startWhepStreaming(url) - } - } - - // 截图:推流预览 - binding.btnCapturePush.setOnClickListener { - val targetView: View? = if (selectedProtocol == Protocol.WHIP) binding.whipPreview else binding.surfaceViewPush - captureSurfaceViewAndSave(targetView, prefix = "push") - } - // 截图:播放 - binding.btnCapturePlay.setOnClickListener { - val targetView: View? = if (isWhepPlaying) whepSurfaceView else binding.surfaceViewPlay - captureSurfaceViewAndSave(targetView, prefix = "play") - } - } - - /* ---------- 协议切换 ---------- */ - private fun switchProtocol(newProtocol: Protocol) { - if (newProtocol == Protocol.RTMP) { - binding.surfaceViewPush.visibility = View.VISIBLE - binding.whipPreview.visibility = View.GONE - setPushPreviewHeader("RTMP") - } else { - binding.surfaceViewPush.visibility = View.GONE - binding.whipPreview.visibility = View.VISIBLE - setPushPreviewHeader("WHIP") - } - - // 根据协议选择对应视图 - val targetView = if (newProtocol == Protocol.RTMP) { - binding.surfaceViewPush - } else { - binding.whipPreview - } - selectedProtocol = newProtocol - streamingManager?.switchProtocol(newProtocol, targetView) - - } - - private fun setProtocolSelectionEnabled(enabled: Boolean) { - binding.protocolGroup.isEnabled = enabled - binding.rbProtocolRtmp.isEnabled = enabled - binding.rbProtocolWhip.isEnabled = enabled - } - - /* ---------- SurfaceHolder.Callback ---------- */ - override fun surfaceCreated(holder: SurfaceHolder) { - when (holder.surface) { - binding.surfaceViewPlay.holder.surface -> { - isPlaySurfaceValid = true - ensurePlaySurfaceFormat() - if (needRecreatePlayer) { - recreatePlayerAndMaybeResume() - } else { - player?.setSurface(holder.surface) - if (shouldResumePlayback && !lastPlayUrl.isNullOrEmpty()) { - player?.prepareAsync(lastPlayUrl!!) - updateStatus(playStatus = "正在连接") - updatePlayButtonStates(false) - shouldResumePlayback = false - } - } - } - binding.surfaceViewPush.holder.surface -> { - isPushSurfaceReady = true - //打印日志 - Log.d("MainActivity", "Push surface created") - Log.d("MainActivity" , hasStartedPushPreview.toString()) - // 仅首次或重建后启动预览,避免重复 startPreview + 触发多次美颜加载 - if (!hasStartedPushPreview) { - try { - streamingManager?.startPreview(); hasStartedPushPreview = true - streamingManager?.setBeautyEnabled(binding.switchBeauty.isChecked) - } catch (_: Exception) { - } - } - } - } - } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - if (holder.surface == binding.surfaceViewPlay.holder.surface) ensurePlaySurfaceFormat() - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - when (holder.surface) { - binding.surfaceViewPlay.holder.surface -> { - isPlaySurfaceValid = false - player?.setSurface(null) - } - binding.surfaceViewPush.holder.surface -> { - Log.d("MainActivity", "Push surface destroyed") - isPushSurfaceReady = false - hasStartedPushPreview = false // 下次重建允许重新 startPreview - // 释放摄像头/预览由流程统一处理 - } - } - } - - /** 权限检测与申请 */ - private fun checkAndRequestPermissions() { - if (permissions.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }) { - setupAll() - } else { - permissionLauncher.launch(permissions) - } - } - - private val permissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { results -> - if (results.values.all { it }) setupAll() else Toast.makeText(this, "需要相机和录音权限才能使用此功能", Toast.LENGTH_LONG).show() - } - - /** 初始化(推流 + 播放) */ - private fun setupAll() { - // 默认显示 RTMP 预览视图 - binding.surfaceViewPush.visibility = if (selectedProtocol == Protocol.RTMP) View.VISIBLE else View.GONE - binding.whipPreview.visibility = if (selectedProtocol == Protocol.WHIP) View.VISIBLE else View.GONE - - // RTMP 预览器配置 - binding.surfaceViewPlay.setZOrderMediaOverlay(false) - binding.surfaceViewPlay.holder.setFormat(PixelFormat.OPAQUE) - // 由 Surface 回调驱动 RTMP 预览生命周期 - binding.surfaceViewPush.holder.addCallback(this) - - binding.whipPreview.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL ) - binding.whipPreview.setEnableHardwareScaler(true) - - val (w, h) = currentResolution() - //配置参数 - streamingManager?.updateStreamConfig( - protocol = selectedProtocol, - width = 1080, - height = 1920, - fps = 40, - videoBitrate = 2_500_000, - audioBitrate = 128_000, - iFrameInterval = 1, - maxRetryCount = 5, - retryDelayMs = 3000, - facing = "front" - ) - - // 初始化 manager 与预览 - try { - if (selectedProtocol == Protocol.RTMP) { - streamingManager?.initialize(binding.surfaceViewPush) - } else { - streamingManager?.initialize(binding.whipPreview) - } - } catch (_: Exception) {} - - // 播放器与回调维持原逻辑 - playerConfig = PlayerConfig.forRtmpLive(enableKiwi = true, rsname = "123") - player = RtmpPlayer(context = this, playerConfig = playerConfig!!) - attachRtmpPlayerStateListener() - binding.surfaceViewPlay.setZOrderMediaOverlay(false) - binding.surfaceViewPlay.holder.setFormat(PixelFormat.OPAQUE) - binding.surfaceViewPlay.holder.addCallback(this) - if (binding.surfaceViewPlay.holder.surface.isValid) { - surfaceCreated(binding.surfaceViewPlay.holder) - } - } - - /** 将Uri解码成合适大小的Bitmap,避免OOM */ - private fun decodeBitmapFromUri(uri: Uri): Bitmap? { - val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } - contentResolver.openInputStream(uri)?.use { input -> BitmapFactory.decodeStream(input, null, options) } - val reqMax = 1280 - var inSample = 1 - val w = options.outWidth; val h = options.outHeight - if (w > reqMax || h > reqMax) { - val halfW = w / 2; val halfH = h / 2 - while ((halfW / inSample) >= reqMax || (halfH / inSample) >= reqMax) { inSample *= 2 } - } - val decodeOpts = BitmapFactory.Options().apply { inSampleSize = inSample } - contentResolver.openInputStream(uri)?.use { input -> return BitmapFactory.decodeStream(input, null, decodeOpts) } - return null - } - - /** 更新状态文本 */ - private fun updateStatus(pushStatus: String? = null, playStatus: String? = null) { - runOnUiThread { - val currentPushStatus = binding.tvStatus.text.split("|")[0].split(":").getOrNull(1)?.trim() ?: "待启动" - val newPushStatus = pushStatus ?: currentPushStatus - if (playStatus != null) this.idelStatus = playStatus - uiState.setPushStatusText(newPushStatus, this.idelStatus) - } - } - - private fun updatePlayButtonStates(enabled: Boolean) { runOnUiThread { uiState.setPlayButtonEnabled(enabled) } } - private fun setPushPreviewHeader(mode: String) { try { uiState.setPushPreviewHeader(mode) } catch (_: Exception) {} } - private fun updateWhepButtonText() { uiState.setWhepButtonText(isWhepPlaying) } - - private fun stopCurrentPlayback() { - when (currentPlayType) { - PlayType.RTMP -> { - try { player?.setSurface(null) } catch (_: Exception) {} - try { player?.stop() } catch (_: Exception) {} - try { player?.release() } catch (_: Exception) {} - try { player?.destroy() } catch (_: Exception) {} - - val cfg = playerConfig ?: PlayerConfig.forRtmpLive(enableKiwi = true, rsname = "123") - player = RtmpPlayer(context = this, playerConfig = cfg) - attachRtmpPlayerStateListener() - - lastPlayUrl = null - shouldResumePlayback = false - currentPlayType = PlayType.NONE - updatePlayButtonStates(true) - uiState.setRtmpButtonText(false) - updateStatus(playStatus = "已停止播放") - - forceRecreatePlaySurface() - } - PlayType.WHEP -> stopWhepStreaming() - PlayType.NONE -> {} - } - } - - // 强制销毁并重建播放 Surface(通过可见性切换触发 surfaceDestroyed/surfaceCreated) - private fun forceRecreatePlaySurface() { - try { - binding.surfaceViewPlay.visibility = View.GONE - binding.surfaceViewPlay.post { - ensurePlaySurfaceFormat() - binding.surfaceViewPlay.visibility = View.VISIBLE - binding.surfaceViewPlay.requestLayout() - } - } catch (_: Exception) {} - } - - /* ---------- WHEP 功能(保留) ---------- */ - private fun startWhepStreaming(url: String) { - stopCurrentPlayback() - val whepUrl = url - try { - // 初始化 WHEP SurfaceViewRenderer - if (whepSurfaceView == null) { - whepSurfaceView = SurfaceViewRenderer(this) - runOnUiThread { - whepSurfaceView?.let { surfaceView -> - try { - if (webrtcEglBase == null) webrtcEglBase = org.webrtc.EglBase.create() - surfaceView.init(webrtcEglBase!!.eglBaseContext, null) - surfaceView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) - surfaceView.setEnableHardwareScaler(true) - val playContainer = binding.surfaceViewPlay.parent as android.view.ViewGroup - val layoutParams = android.view.ViewGroup.LayoutParams( - android.view.ViewGroup.LayoutParams.MATCH_PARENT, - android.view.ViewGroup.LayoutParams.MATCH_PARENT - ) - surfaceView.layoutParams = layoutParams - surfaceView.setZOrderOnTop(false) - surfaceView.setZOrderMediaOverlay(true) - playContainer.addView(surfaceView) - binding.surfaceViewPlay.visibility = View.GONE - surfaceView.visibility = View.VISIBLE - playContainer.requestLayout(); surfaceView.requestLayout() - } catch (e: Exception) { Log.e("MainActivity", "Error initializing WHEP view", e); throw e } - } - } - } - // 启动播放 - coroutineScope.launch(Dispatchers.Main) { - try { - delay(300) - whepClient = WhepClient(this@MainActivity, coroutineScope, whepSurfaceView!!, webrtcEglBase!!.eglBaseContext) - attachWhepPlayerStateListener() - whepClient?.play(whepUrl) - runOnUiThread { - isWhepPlaying = true - currentPlayType = PlayType.WHEP - updateWhepButtonText() - updatePlayButtonStates(false) - Toast.makeText(this@MainActivity, "WHEP拉流已启动", Toast.LENGTH_SHORT).show() - } - } catch (e: Exception) { - runOnUiThread { - isWhepPlaying = false; whepClient = null; currentPlayType = PlayType.NONE - updateWhepButtonText(); updatePlayButtonStates(true) - updateStatus(playStatus = "WHEP播放失败: ${e.message}") - Toast.makeText(this@MainActivity, "WHEP拉流启动失败: ${e.message}", Toast.LENGTH_LONG).show() - whepSurfaceView?.let { surfaceView -> - try { val parent = surfaceView.parent as? android.view.ViewGroup; parent?.removeView(surfaceView) } catch (_: Exception) {} - } - whepSurfaceView = null - binding.surfaceViewPlay.visibility = View.VISIBLE - } - } - } - } catch (e: Exception) { Toast.makeText(this, "WHEP拉流初始化失败: ${e.message}", Toast.LENGTH_LONG).show(); updateStatus(playStatus = "WHEP初始化失败") } - } - - private fun stopWhepStreaming() { - try { whepClient?.stop(); whepClient = null; runOnUiThread { - whepSurfaceView?.let { surfaceView -> - try { surfaceView.release() } catch (_: Exception) {} - val parent = surfaceView.parent as? android.view.ViewGroup; parent?.removeView(surfaceView) - } - whepSurfaceView = null - binding.surfaceViewPlay.visibility = View.VISIBLE - ensurePlaySurfaceFormat() - } } catch (_: Exception) {} - try { webrtcEglBase?.release() } catch (_: Exception) {} - webrtcEglBase = null - isWhepPlaying = false; currentPlayType = PlayType.NONE - updateWhepButtonText(); updatePlayButtonStates(true) - updateStatus(playStatus = "WHEP播放已停止") - Toast.makeText(this, "WHEP拉流已停止", Toast.LENGTH_SHORT).show() - } - - override fun onDestroy() { - super.onDestroy() - // 停止 WHEP - if (isWhepPlaying) stopWhepStreaming() - try { webrtcEglBase?.release(); webrtcEglBase = null } catch (_: Exception) {} - // 释放 StreamingManager - streamingManager?.release(); streamingManager = null - // 完整销毁播放器(包含协程作用域和 native profile) - try { player?.destroy() } catch (_: Exception) { try { player?.release() } catch (_: Exception) {} } - player = null - - coroutineScope.cancel() - try { binding.surfaceViewPlay.holder.removeCallback(this) } catch (_: Exception) {} - try { binding.surfaceViewPush.holder.removeCallback(this) } catch (_: Exception) {} - try { coroutineScope.cancel() } catch (_: Exception) {} - } - - private fun recreatePlayerAndMaybeResume() { - val cfg = playerConfig ?: PlayerConfig.forRtmpLive(enableKiwi = true, rsname = "123") - player = RtmpPlayer(context = this, playerConfig = cfg) - attachRtmpPlayerStateListener() - ensurePlaySurfaceFormat() - val holder = binding.surfaceViewPlay.holder - if (holder.surface != null && holder.surface.isValid) player?.setSurface(holder.surface) - if (shouldResumePlayback && !lastPlayUrl.isNullOrEmpty()) { - player?.prepareAsync(lastPlayUrl!!) - updatePlayButtonStates(false) - uiState.setRtmpButtonText(true) - shouldResumePlayback = false - } - needRecreatePlayer = false - } - - private fun attachRtmpPlayerStateListener() { - player?.setSCPlayerStateListener { state, detail -> - Log.d("MainActivity", "Player State: $state, Detail: $detail") - when (state) { - SCPlayerState.SCPlayerStateConnecting -> runOnUiThread { - val reconnect = detail?.contains("reconnecting") == true - updateStatus(playStatus = if (reconnect) "正在重连(RTMP)" else "正在连接(RTMP)") - updatePlayButtonStates(false) - uiState.setRtmpButtonText(true) - } - SCPlayerState.SCPlayerStatePlaying -> runOnUiThread { updateStatus(playStatus = "播放中(RTMP)"); updatePlayButtonStates(false); uiState.setRtmpButtonText(true) } - SCPlayerState.SCPlayerStatePaused -> runOnUiThread { updateStatus(playStatus = "暂停播放(RTMP)") } - SCPlayerState.SCPlayerStateStoppedOrEnded -> runOnUiThread { - val text = if (detail == "completed") "播放完成(RTMP)" else "已结束播放(RTMP)" - updateStatus(playStatus = text); updatePlayButtonStates(true); uiState.setRtmpButtonText(false) - try { player?.setSurface(null) } catch (_: Exception) {} - playSurfaceManager.clear() - } - SCPlayerState.SCPlayerStateFailed -> runOnUiThread { - updateStatus(playStatus = "播放错误(RTMP)"); updatePlayButtonStates(true); uiState.setRtmpButtonText(false) - try { player?.setSurface(null) } catch (_: Exception) {} - playSurfaceManager.clear() - } - SCPlayerState.SCPlayerStateIdle -> { } - } - } - } - - private fun attachWhepPlayerStateListener() { - whepClient?.setSCPlayerStateListener { state, detail -> - Log.d("MainActivity", "WHEP Player State: $state, Detail: $detail") - when (state) { - SCPlayerState.SCPlayerStateConnecting -> runOnUiThread { - val statusText = if (detail == "ICE connected") "已连接(WHEP)" else "正在连接(WHEP)" - updateStatus(playStatus = statusText); updatePlayButtonStates(false) - } - SCPlayerState.SCPlayerStatePlaying -> runOnUiThread { updateStatus(playStatus = "播放中(WHEP)"); updatePlayButtonStates(false) } - SCPlayerState.SCPlayerStateStoppedOrEnded -> runOnUiThread { - isWhepPlaying = false - updateStatus(playStatus = "WHEP播放已停止"); updatePlayButtonStates(true) - updateWhepButtonText() - } - SCPlayerState.SCPlayerStateFailed -> runOnUiThread { - isWhepPlaying = false - updateStatus(playStatus = "WHEP失败: ${detail ?: "未知错误"}"); updatePlayButtonStates(true) - updateWhepButtonText() - } - else -> { } - } - } - } - - /** 计算当前选中分辨率,统一竖屏(宽<高) */ - private fun currentResolution(): Pair { - var (w, h) = when (binding.resolutionGroup.checkedRadioButtonId) { - R.id.res360p -> 360 to 640 - R.id.res540p -> 540 to 960 - R.id.res720p -> 720 to 1280 - else -> 720 to 1280 - } - if (w > h) { val t = w; w = h; h = t } - return w to h - } - - /** 根据不同协议组装播放 URL */ - private fun buildPlayUrl(protocolType: String, appName: String, streamName: String): String { - return when (protocolType) { - "RTMP" -> { - // RTMP 播放格式: rtmp://rtmp.sellycloud.pull/appName/streamName - "rtmp://rtmp.sellycloud.pull/$appName/$streamName" - } - "WHEP" -> { - // WHEP 播放格式 (WHIP推流对应WHEP拉流): https://rtmp.sellycloud.pull/whep/appName/streamName - "http://rtmp.sellycloud.pull/$appName/$streamName" - } - else -> { - // 默认使用 RTMP - "" - } - } - } - - /** 确保播放 Surface 的像素格式与叠放层设置正确(防止再次播放偏蓝) */ - private fun ensurePlaySurfaceFormat() { - playSurfaceManager.ensureOpaqueFormat() - - } - - - /** 使用 PixelCopy 截取 Surface 内容并保存到相册(Android 8.0+)。更低版本给出提示。 */ - private fun captureSurfaceViewAndSave(view: View?, prefix: String) { - if (view == null) { Toast.makeText(this, "当前没有可用的视图进行截图", Toast.LENGTH_SHORT).show(); return } - if (view.width <= 0 || view.height <= 0) { Toast.makeText(this, "视图尚未布局完成,稍后再试", Toast.LENGTH_SHORT).show(); return } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - Toast.makeText(this, "当前系统版本不支持该截图方式(需Android 8.0+)", Toast.LENGTH_LONG).show(); return - } - // 仅支持 SurfaceView/其子类 - 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) { - coroutineScope.launch(Dispatchers.IO) { - val ok = saveBitmapToGallery(bmp, prefix) - launch(Dispatchers.Main) { - Toast.makeText(this@MainActivity, 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() - } - } - - /** 保存位图到系统相册(按API等级分别处理) */ - 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 try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val values = android.content.ContentValues().apply { - put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, filename) - put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png") - put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/") - put(android.provider.MediaStore.Images.Media.IS_PENDING, 1) - } - val resolver = contentResolver - val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) - if (uri != null) { - resolver.openOutputStream(uri)?.use { out -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) - } - values.clear() - values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0) - resolver.update(uri, values, null, null) - true - } else false - } else { - // API 29 以下,保存到公共图片目录(需要WRITE_EXTERNAL_STORAGE权限,已在Manifest按maxSdk申明) - val picturesDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_PICTURES) - val targetDir = java.io.File(picturesDir, "RTMPDemo").apply { if (!exists()) mkdirs() } - val file = java.io.File(targetDir, filename) - java.io.FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } - // 通知相册扫描 - val values = android.content.ContentValues().apply { - put(android.provider.MediaStore.Images.Media.DATA, file.absolutePath) - put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png") - put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, filename) - } - contentResolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) - true - } - } catch (e: Exception) { - Log.e("MainActivity", "saveBitmapToGallery error", e) - false - } - } - - private fun bundleToMap(bundle: Bundle?): Map { - if (bundle == null) return emptyMap() - val map = mutableMapOf() - for (key in bundle.keySet()) { - val value = bundle.get(key) - map[key] = when (value) { - is Bundle -> bundleToMap(value) - is IntArray -> value.toList() - is LongArray -> value.toList() - is FloatArray -> value.toList() - is DoubleArray -> value.toList() - is BooleanArray -> value.toList() - is ByteArray -> value.joinToString(prefix = "[", postfix = "]") - is Array<*> -> value.toList() - else -> value - } - } - return map - } - - override fun onSupportNavigateUp(): Boolean { - onBackPressedDispatcher.onBackPressed() - return true - } -} diff --git a/example/src/main/java/com/demo/SellyCloudSDK/live/MultiPlayActivity.kt b/example/src/main/java/com/demo/SellyCloudSDK/live/MultiPlayActivity.kt deleted file mode 100644 index 1fd6205..0000000 --- a/example/src/main/java/com/demo/SellyCloudSDK/live/MultiPlayActivity.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.demo.SellyCloudSDK.live - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.widget.Button -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.TextView -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import com.demo.SellyCloudSDK.R -import com.sellycloud.sellycloudsdk.MultiRtmpPlayer -import com.sellycloud.sellycloudsdk.PlayerConfig - -class MultiPlayActivity : AppCompatActivity(), MultiRtmpPlayer.MultiRtmpPlayerListener { - - private lateinit var etNewUrl: EditText - private lateinit var btnAddStream: Button - private lateinit var btnStartAll: Button - private lateinit var btnStopAll: Button - private lateinit var streamsContainer: LinearLayout - - private lateinit var multiPlayer: MultiRtmpPlayer - private var streamCounter = 1 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_multi_play) - - etNewUrl = findViewById(R.id.etNewUrl) - btnAddStream = findViewById(R.id.btnAddStream) - btnStartAll = findViewById(R.id.btnStartAll) - btnStopAll = findViewById(R.id.btnStopAll) - streamsContainer = findViewById(R.id.streamsContainer) - - multiPlayer = MultiRtmpPlayer(this, this, PlayerConfig.forRtmpLive()) - - btnAddStream.setOnClickListener { - val urlInput = etNewUrl.text.toString().trim() - val id = "stream_${streamCounter++}" - - val config = if (urlInput.isEmpty()) { - // 未输入 URL 时,示例启用 Kiwi 使用默认 rs 标识,可按需替换 - PlayerConfig.forRtmpLive(enableKiwi = true, rsname = "123") - } else { - PlayerConfig.forRtmpLive() - } - - val ok = multiPlayer.addStream(id, urlInput.ifEmpty { "rtmp://placeholder/kiwi" }, config) - if (!ok) { - Toast.makeText(this, "添加失败:ID重复", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - addStreamItemView(id) - } - - btnStartAll.setOnClickListener { - multiPlayer.currentStreams().forEach { id -> - if (multiPlayer.isPrepared(id)) multiPlayer.start(id) else multiPlayer.prepareAsync(id) - } - } - btnStopAll.setOnClickListener { - multiPlayer.currentStreams().forEach { id -> multiPlayer.stop(id) } - } - } - - private fun addStreamItemView(streamId: String) { - val item = LayoutInflater.from(this).inflate(R.layout.item_stream_player, streamsContainer, false) - val tvTitle = item.findViewById(R.id.tvTitle) - val tvStatus = item.findViewById(R.id.tvStatus) - val surfaceView = item.findViewById(R.id.surfaceView) - val btnPrepare = item.findViewById