diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 754f333fa..dc386642d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,42 +1,46 @@ plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.jetbrains.kotlin) + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin) } android { - namespace = "com.pedro.streamer" - compileSdk = 36 + namespace = "com.pedro.streamer" + compileSdk = 35 - defaultConfig { - applicationId = "com.pedro.streamer" - minSdk = 16 - targetSdk = 36 - versionCode = project.version.toString().replace(".", "").toInt() - versionName = project.version.toString() - multiDexEnabled = true - } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + defaultConfig { + applicationId = "com.pedro.streamer" + minSdk = 21 + targetSdk = 35 + versionCode = libs.versions.versionCode.get().toInt() + versionName = libs.versions.versionName.get() + multiDexEnabled = true + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + buildConfig = true + viewBinding = true } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlin { - jvmToolchain(17) - } - buildFeatures { - buildConfig = true - } } dependencies { - implementation(project(":library")) - implementation(project(":extra-sources")) - implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.multidex) + implementation(project(":library")) + implementation(project(":extra-sources")) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.multidex) + implementation(libs.firebase.crashlytics.buildtools) + implementation(libs.eventbus) + implementation(libs.recyclerview) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a627981ec..855b761fd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,77 +1,109 @@ - + - - - - + + + - - - - - - - - - - + + + + + + + - - + + + + + + + + + + - - + + + - - - + android:requestLegacyExternalStorage="true" + android:supportsRtl="true" + android:theme="@style/AppTheme" + android:usesCleartextTraffic="true"> + + + + + + + + + + - - - + - + - + - + + + + - - + + + + + \ No newline at end of file diff --git a/app/src/main/ic_hohem-playstore.png b/app/src/main/ic_hohem-playstore.png new file mode 100644 index 000000000..44f218d5f Binary files /dev/null and b/app/src/main/ic_hohem-playstore.png differ diff --git a/app/src/main/java/com/pedro/streamer/MainActivity.kt b/app/src/main/java/com/pedro/streamer/MainActivity.kt index 7aae9a946..6c069f5e8 100644 --- a/app/src/main/java/com/pedro/streamer/MainActivity.kt +++ b/app/src/main/java/com/pedro/streamer/MainActivity.kt @@ -102,7 +102,7 @@ class MainActivity : AppCompatActivity() { activities.add( ActivityLink( Intent(this, RotationActivity::class.java), - getString(R.string.rotation_rtmp), VERSION_CODES.LOLLIPOP + getString(R.string.camera_live), VERSION_CODES.LOLLIPOP ) ) } diff --git a/app/src/main/java/com/pedro/streamer/file/FromFileActivity.kt b/app/src/main/java/com/pedro/streamer/file/FromFileActivity.kt index c74839700..852c40c63 100644 --- a/app/src/main/java/com/pedro/streamer/file/FromFileActivity.kt +++ b/app/src/main/java/com/pedro/streamer/file/FromFileActivity.kt @@ -126,7 +126,7 @@ class FromFileActivity : AppCompatActivity(), ConnectChecker, bRecord.setOnClickListener { if (genericFromFile.isRecording) { genericFromFile.stopRecord() - bRecord.setImageResource(R.drawable.record_icon) + bRecord.setImageResource(R.drawable.ic_record_start) PathUtils.updateGallery(this, recordPath) if (!genericFromFile.isStreaming) ScreenOrientation.unlockScreen(this) } else if (genericFromFile.isStreaming || prepare()) { @@ -135,10 +135,10 @@ class FromFileActivity : AppCompatActivity(), ConnectChecker, if (!folder.exists()) folder.mkdir() val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) recordPath = "${folder.absolutePath}/${sdf.format(Date())}.mp4" - bRecord.setImageResource(R.drawable.pause_icon) + bRecord.setImageResource(R.drawable.ic_record_pause) genericFromFile.startRecord(recordPath) { status -> if (status == RecordController.Status.RECORDING) { - bRecord.setImageResource(R.drawable.stop_icon) + bRecord.setImageResource(R.drawable.ic_record_stop) } } ScreenOrientation.lockScreen(this) @@ -165,7 +165,7 @@ class FromFileActivity : AppCompatActivity(), ConnectChecker, genericFromFile.stopAudioDevice() if (genericFromFile.isRecording) { genericFromFile.stopRecord() - bRecord.setImageResource(R.drawable.record_icon) + bRecord.setImageResource(R.drawable.ic_record_start) } if (genericFromFile.isStreaming) { genericFromFile.stopStream() diff --git a/app/src/main/java/com/pedro/streamer/oldapi/OldApiActivity.kt b/app/src/main/java/com/pedro/streamer/oldapi/OldApiActivity.kt index 78e159346..24a6b106f 100644 --- a/app/src/main/java/com/pedro/streamer/oldapi/OldApiActivity.kt +++ b/app/src/main/java/com/pedro/streamer/oldapi/OldApiActivity.kt @@ -91,7 +91,7 @@ class OldApiActivity : AppCompatActivity(), ConnectChecker, TextureView.SurfaceT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { if (genericCamera1.isRecording) { genericCamera1.stopRecord() - bRecord.setImageResource(R.drawable.record_icon) + bRecord.setImageResource(R.drawable.ic_record_start) PathUtils.updateGallery(this, recordPath) if (!genericCamera1.isStreaming) ScreenOrientation.unlockScreen(this) } else if (genericCamera1.isStreaming || prepare()) { @@ -99,10 +99,10 @@ class OldApiActivity : AppCompatActivity(), ConnectChecker, TextureView.SurfaceT if (!folder.exists()) folder.mkdir() val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) recordPath = "${folder.absolutePath}/${sdf.format(Date())}.mp4" - bRecord.setImageResource(R.drawable.pause_icon) + bRecord.setImageResource(R.drawable.ic_record_pause) genericCamera1.startRecord(recordPath) { status -> if (status == RecordController.Status.RECORDING) { - bRecord.setImageResource(R.drawable.stop_icon) + bRecord.setImageResource(R.drawable.ic_record_stop) } } ScreenOrientation.lockScreen(this) @@ -180,7 +180,7 @@ class OldApiActivity : AppCompatActivity(), ConnectChecker, TextureView.SurfaceT override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && genericCamera1.isRecording) { genericCamera1.stopRecord() - bRecord.setBackgroundResource(R.drawable.record_icon) + bRecord.setBackgroundResource(R.drawable.ic_record_start) PathUtils.updateGallery(this, recordPath) } if (genericCamera1.isStreaming) { diff --git a/app/src/main/java/com/pedro/streamer/rotation/CameraActivity.kt b/app/src/main/java/com/pedro/streamer/rotation/CameraActivity.kt new file mode 100644 index 000000000..61013e176 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/CameraActivity.kt @@ -0,0 +1,362 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.streamer.rotation + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.pedro.encoder.input.sources.audio.MicrophoneSource +import com.pedro.encoder.input.sources.video.Camera1Source +import com.pedro.encoder.input.sources.video.Camera2Source +import com.pedro.extrasources.BitmapSource +import com.pedro.extrasources.CameraUvcSource +import com.pedro.extrasources.CameraXSource +import com.pedro.streamer.R +import com.pedro.streamer.rotation.eventbus.BroadcastBackPressedEvent +import com.pedro.streamer.rotation.topmethod.onBackPressedListener +import com.pedro.streamer.utils.FilterMenu +import com.pedro.streamer.utils.Logger +import com.pedro.streamer.utils.toast +import com.pedro.streamer.utils.updateMenuColor +import org.greenrobot.eventbus.EventBus + +/** + * Created by pedro on 22/3/22. + */ +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +class CameraActivity : AppCompatActivity(), OnTouchListener { + companion object { + private const val TAG = "CameraActivity" + private const val EXIT_TIME_INTERVAL = 2000 + } + + private val liveFragment = LiveFragment.getInstance() + private val filterMenu: FilterMenu by lazy { FilterMenu(this) } + private var currentVideoSource: MenuItem? = null + private var currentAudioSource: MenuItem? = null + private var currentOrientation: MenuItem? = null + private var currentFilter: MenuItem? = null + private var currentPlatform: MenuItem? = null + private var mClickTime: Long = 0 + private var requestPermissionLauncher: ActivityResultLauncher>? = null + private var hasCheckPermission: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.rotation_activity) + requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()){ result: Map -> + handlePermissionResults(result) + } + checkAndRequestPermissions() + initBackEventListener() + } + + private fun buildRequiredPermissions(): Array { + val perms = mutableListOf() + perms.add(Manifest.permission.CAMERA) + perms.add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + perms.add(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + perms.add(Manifest.permission.READ_MEDIA_IMAGES) + perms.add(Manifest.permission.READ_MEDIA_VIDEO) + } else { + perms.add(Manifest.permission.READ_EXTERNAL_STORAGE) + perms.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + return perms.toTypedArray() + } + + private fun checkAndRequestPermissions() { + hasCheckPermission = true + val required = buildRequiredPermissions() + val notGranted = required.filter { perm -> + ContextCompat.checkSelfPermission(this, perm) != PackageManager.PERMISSION_GRANTED + } + if(notGranted.isEmpty()){ + onAllPermissionGranted() + }else{ + requestPermissionLauncher?.launch(notGranted.toTypedArray()) + } + } + + private fun handlePermissionResults(results: Map) { + Logger.d(TAG, "handlePermissionResults: results = $results") + val denied: Set = results.filter { !it.value }.keys + if(denied.isEmpty()){ + onAllPermissionGranted() + return + } + // 检查是否有“永久拒绝”(即用户勾选了 Don't ask again / 不再询问) + val permanentlyDenied = denied.filter { perm -> + isPermissionPermanentlyDenied(perm) + } + if(permanentlyDenied.isNotEmpty()){ + // 有永久拒绝 — 强制引导到设置页 + showPermissionDeniedPermanentlyDialog(permanentlyDenied) + }else{ + // 只是普通拒绝 — 给用户一个明确说明和再次请求的机会或退出 + showPermissionRationaleDialog(denied) + } + } + + /** + * 判断某个权限是否被“永久拒绝”(拒绝并不再询问) + */ + private fun isPermissionPermanentlyDenied(permission: String): Boolean { + val denied = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_DENIED + // shouldShowRequestPermissionRationale -> false 表示:要么从未请求过,要么永久拒绝 + val shouldShow = ActivityCompat.shouldShowRequestPermissionRationale(this, permission) + return denied && !shouldShow + } + + private fun onAllPermissionGranted(){ + Logger.d(TAG, "onAllPermissionGranted: ") + supportFragmentManager.beginTransaction().add(R.id.container, liveFragment).commit() + } + + private fun showPermissionRationaleDialog(deniedPermissions: Set) { + Logger.d(TAG, "showPermissionRationaleDialog: deniedPermissions = $deniedPermissions") + val message = buildRationaleMessage(deniedPermissions) + AlertDialog.Builder(this) + .setTitle("需要授权以继续使用") + .setMessage(message) + .setCancelable(false) + .setPositiveButton("重新授权") { _, _ -> + requestPermissionLauncher?.launch(deniedPermissions.toTypedArray()) + } + .setNegativeButton("退出应用") { _, _ -> + finishAffinity() + } + .show() + } + + private fun showPermissionDeniedPermanentlyDialog(permanentlyDenied: List) { + Logger.d(TAG, "showPermissionDeniedPermanentlyDialog: permanentlyDenied = $permanentlyDenied") + val message = buildRationaleMessage(permanentlyDenied.toSet()) + "\n\n请在应用设置中手动开启权限,否则无法使用本应用。" + AlertDialog.Builder(this) + .setTitle("权限被禁用") + .setMessage(message) + .setCancelable(false) + .setPositiveButton("打开设置") { _, _ -> + openAppSettings() + } + .setNegativeButton("退出应用") { _, _ -> + finishAffinity() + } + .show() + } + + private fun openAppSettings() { + hasCheckPermission = false + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null) + ) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } + + private fun buildRationaleMessage(deniedPermissions: Set): String { + val human = deniedPermissions.map { perm -> + when (perm) { + Manifest.permission.CAMERA -> "相机(拍摄/扫码)" + Manifest.permission.RECORD_AUDIO -> "麦克风(语音/录音)" + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE -> "相册/媒体(读取照片)" + else -> perm + } + } + return "应用需要以下权限:\n• ${human.joinToString("\n• ")}\n以正常运行。" + } + + override fun onResume() { + super.onResume() + // 用户可能从设置页回来,这里再次检查权限 + if(!hasCheckPermission){ + checkAndRequestPermissionsIfNeeded() + } + } + + private fun checkAndRequestPermissionsIfNeeded() { + val required = buildRequiredPermissions() + val notGranted = required.filter { perm -> + ContextCompat.checkSelfPermission(this, perm) != PackageManager.PERMISSION_GRANTED + } + if(notGranted.isEmpty()){ + onAllPermissionGranted() + }else{ + AlertDialog.Builder(this) + .setTitle("权限未就绪") + .setMessage("必要权限仍然未授权,应用无法继续运行。") + .setCancelable(false) + .setPositiveButton("退出应用") { _, _ -> + finishAffinity() + } + .show() + } + } + + private fun initBackEventListener() { + onBackPressedListener(true) { + EventBus.getDefault().post(BroadcastBackPressedEvent()) + } + } + + fun handleBackEvent() { + Logger.d(TAG, "handleBackEvent: ") + if ((System.currentTimeMillis() - mClickTime) > EXIT_TIME_INTERVAL) { + toast("Press again to exit app") + mClickTime = System.currentTimeMillis() + } else { + finish() + } + } + + /*override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rotation_menu, menu) + val defaultVideoSource = menu.findItem(R.id.video_source_camera2) + val defaultAudioSource = menu.findItem(R.id.audio_source_microphone) + val defaultOrientation = menu.findItem(R.id.orientation_horizontal) + val defaultFilter = menu.findItem(R.id.no_filter) + val defaultPlatform = menu.findItem(R.id.platform_youtube) + currentVideoSource = defaultVideoSource.updateMenuColor(this, currentVideoSource) + currentAudioSource = defaultAudioSource.updateMenuColor(this, currentAudioSource) + currentOrientation = defaultOrientation.updateMenuColor(this, currentOrientation) + currentFilter = defaultFilter.updateMenuColor(this, currentFilter) + currentPlatform = defaultPlatform.updateMenuColor(this, currentPlatform) + return true + }*/ + +// override fun onBackPressed() { +// super.onBackPressed() +// Logger.d(TAG, "onBackPressed: ") +// } + +// override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { +// if(keyCode == KeyEvent.KEYCODE_BACK){ +// Logger.d(TAG, "onKeyDown: ") +// +// } +// return super.onKeyDown(keyCode, event) +// } + + /*override fun onOptionsItemSelected(item: MenuItem): Boolean { + try { + when (item.itemId) { + R.id.video_source_camera1 -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + liveFragment.genericStream.changeVideoSource(Camera1Source(applicationContext)) + } + + R.id.video_source_camera2 -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + liveFragment.genericStream.changeVideoSource(Camera2Source(applicationContext)) + } + + R.id.video_source_camerax -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + liveFragment.genericStream.changeVideoSource(CameraXSource(applicationContext)) + } + + R.id.video_source_camera_uvc -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + liveFragment.genericStream.changeVideoSource(CameraUvcSource()) + } + + R.id.video_source_bitmap -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) + liveFragment.genericStream.changeVideoSource(BitmapSource(bitmap)) + } + + R.id.audio_source_microphone -> { + currentAudioSource = item.updateMenuColor(this, currentAudioSource) + liveFragment.genericStream.changeAudioSource(MicrophoneSource()) + } + + R.id.orientation_horizontal -> { + currentOrientation = item.updateMenuColor(this, currentOrientation) + liveFragment.setOrientationMode(false) + } + + R.id.orientation_vertical -> { + currentOrientation = item.updateMenuColor(this, currentOrientation) + liveFragment.setOrientationMode(true) + } + + R.id.platform_huya -> { + currentPlatform = item.updateMenuColor(this, currentPlatform) + liveFragment.streamUrl.setText(R.string.stream_url_huya) + } + + R.id.platform_tiktok -> { + currentPlatform = item.updateMenuColor(this, currentPlatform) + liveFragment.streamUrl.setText(R.string.stream_url_tiktok) + } + + R.id.platform_youtube -> { + currentPlatform = item.updateMenuColor(this, currentPlatform) + liveFragment.streamUrl.setText(R.string.stream_url_youtube) + } + + else -> { + val result = filterMenu.onOptionsItemSelected( + item, + liveFragment.genericStream.getGlInterface() + ) + if (result) currentFilter = item.updateMenuColor(this, currentFilter) + return result + } + } + } catch (e: IllegalArgumentException) { +// toast("Change source error: ${e.message}") + } + return super.onOptionsItemSelected(item) + }*/ + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(view: View, motionEvent: MotionEvent): Boolean { + if (filterMenu.spriteGestureController.spriteTouched(view, motionEvent)) { + filterMenu.spriteGestureController.moveSprite(view, motionEvent) + filterMenu.spriteGestureController.scaleSprite(motionEvent) + return true + } + return false + } +} + diff --git a/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt b/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt index c0691e9d1..5a884c69c 100644 --- a/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt +++ b/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt @@ -33,14 +33,15 @@ import com.pedro.common.ConnectChecker import com.pedro.encoder.input.sources.video.Camera1Source import com.pedro.encoder.input.sources.video.Camera2Source import com.pedro.extrasources.CameraXSource -import com.pedro.library.base.recording.RecordController import com.pedro.library.generic.GenericStream import com.pedro.library.util.BitrateAdapter import com.pedro.streamer.R -import com.pedro.streamer.utils.PathUtils +import com.pedro.streamer.rotation.eventbus.BroadcastBackPressedEvent +import com.pedro.streamer.utils.Logger import com.pedro.streamer.utils.toast -import java.text.SimpleDateFormat -import java.util.Date +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode import java.util.Locale /** @@ -68,169 +69,242 @@ import java.util.Locale * [com.pedro.library.srt.SrtStream] */ @RequiresApi(Build.VERSION_CODES.LOLLIPOP) -class CameraFragment: Fragment(), ConnectChecker { - - companion object { - fun getInstance(): CameraFragment = CameraFragment() - } - - val genericStream: GenericStream by lazy { - GenericStream(requireContext(), this).apply { - getGlInterface().autoHandleOrientation = true - getStreamClient().setBitrateExponentialFactor(0.5f) - } - } - private lateinit var surfaceView: SurfaceView - private lateinit var bStartStop: ImageView - private lateinit var txtBitrate: TextView - val width = 640 - val height = 480 - val vBitrate = 1200 * 1000 - private var rotation = 0 - private val sampleRate = 32000 - private val isStereo = true - private val aBitrate = 128 * 1000 - private var recordPath = "" - //Bitrate adapter used to change the bitrate on fly depend of the bandwidth. - private val bitrateAdapter = BitrateAdapter { - genericStream.setVideoBitrateOnFly(it) - }.apply { - setMaxBitrate(vBitrate + aBitrate) - } - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_camera, container, false) - bStartStop = view.findViewById(R.id.b_start_stop) - val bRecord = view.findViewById(R.id.b_record) - val bSwitchCamera = view.findViewById(R.id.switch_camera) - val etUrl = view.findViewById(R.id.et_rtp_url) - - txtBitrate = view.findViewById(R.id.txt_bitrate) - surfaceView = view.findViewById(R.id.surfaceView) - (activity as? RotationActivity)?.let { - surfaceView.setOnTouchListener(it) - } - surfaceView.holder.addCallback(object: SurfaceHolder.Callback { - override fun surfaceCreated(holder: SurfaceHolder) { - if (!genericStream.isOnPreview) genericStream.startPreview(surfaceView) - } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - genericStream.getGlInterface().setPreviewResolution(width, height) - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - if (genericStream.isOnPreview) genericStream.stopPreview() - } - - }) - - bStartStop.setOnClickListener { - if (!genericStream.isStreaming) { - genericStream.startStream(etUrl.text.toString()) - bStartStop.setImageResource(R.drawable.stream_stop_icon) - } else { - genericStream.stopStream() - bStartStop.setImageResource(R.drawable.stream_icon) - } - } - bRecord.setOnClickListener { - if (!genericStream.isRecording) { - val folder = PathUtils.getRecordPath() - if (!folder.exists()) folder.mkdir() - val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) - recordPath = "${folder.absolutePath}/${sdf.format(Date())}.mp4" - genericStream.startRecord(recordPath) { status -> - if (status == RecordController.Status.RECORDING) { - bRecord.setImageResource(R.drawable.stop_icon) - } +class CameraFragment : Fragment(), ConnectChecker { + + companion object { + fun getInstance(): CameraFragment = CameraFragment() + private const val TAG = "CameraFragment" + } + + enum class Resolution { + _1080P, _720P + } + + val genericStream: GenericStream by lazy { + GenericStream(requireContext(), this).apply { + getGlInterface().autoHandleOrientation = true + getStreamClient().setBitrateExponentialFactor(0.5f) } - bRecord.setImageResource(R.drawable.pause_icon) - } else { - genericStream.stopRecord() - bRecord.setImageResource(R.drawable.record_icon) - PathUtils.updateGallery(requireContext(), recordPath) - } - } - bSwitchCamera.setOnClickListener { - when (val source = genericStream.videoSource) { - is Camera1Source -> source.switchCamera() - is Camera2Source -> source.switchCamera() - is CameraXSource -> source.switchCamera() - } - } - return view - } - - fun setOrientationMode(isVertical: Boolean) { - val wasOnPreview = genericStream.isOnPreview - genericStream.release() - rotation = if (isVertical) 90 else 0 - prepare() - if (wasOnPreview) genericStream.startPreview(surfaceView) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - prepare() - genericStream.getStreamClient().setReTries(10) - } - - private fun prepare() { - val prepared = try { - genericStream.prepareVideo(width, height, vBitrate, rotation = rotation) - && genericStream.prepareAudio(sampleRate, isStereo, aBitrate) - } catch (_: IllegalArgumentException) { - false - } - if (!prepared) { - toast("Audio or Video configuration failed") - activity?.finish() - } - } - - override fun onDestroy() { - super.onDestroy() - genericStream.release() - } - - override fun onConnectionStarted(url: String) { - } - - override fun onConnectionSuccess() { - toast("Connected") - } - - override fun onConnectionFailed(reason: String) { - if (genericStream.getStreamClient().reTry(5000, reason, null)) { - toast("Retry") - } else { - genericStream.stopStream() - bStartStop.setImageResource(R.drawable.stream_icon) - toast("Failed: $reason") - } - } - - override fun onNewBitrate(bitrate: Long) { - bitrateAdapter.adaptBitrate(bitrate, genericStream.getStreamClient().hasCongestion()) - txtBitrate.text = String.format(Locale.getDefault(), "%.1f mb/s", bitrate / 1000_000f) - } - - override fun onDisconnect() { - txtBitrate.text = String() - toast("Disconnected") - } - - override fun onAuthError() { - genericStream.stopStream() - bStartStop.setImageResource(R.drawable.stream_icon) - toast("Auth error") - } - - override fun onAuthSuccess() { - toast("Auth success") - } + } + private lateinit var surfaceView: SurfaceView + private lateinit var bStartStop: ImageView + private lateinit var txtBitrate: TextView + lateinit var streamUrl: EditText + + // private val width = 640 +// private val height = 480 + private var width = 1440 + private var height = 1080 + + // private val vBitrate = 1200 * 1000 + private var vBitrate = 2500 * 1000 + private var rotation = 0 + private val sampleRate = 32000 + private val isStereo = true + private val aBitrate = 128 * 1000 + private var recordPath = "" + private var mCurResolution = Resolution._1080P + + //Bitrate adapter used to change the bitrate on fly depend of the bandwidth. + private val bitrateAdapter = BitrateAdapter { + genericStream.setVideoBitrateOnFly(it) + }.apply { + setMaxBitrate(vBitrate + aBitrate) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_camera, container, false) + bStartStop = view.findViewById(R.id.b_start_stop) + val bRecord = view.findViewById(R.id.b_record) + val bSwitchCamera = view.findViewById(R.id.switch_camera) + streamUrl = view.findViewById(R.id.et_rtp_url) + + txtBitrate = view.findViewById(R.id.txt_bitrate) + surfaceView = view.findViewById(R.id.surfaceView) + (activity as? RotationActivity)?.let { + surfaceView.setOnTouchListener(it) + } + surfaceView.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + holder.setKeepScreenOn(true) + if (!genericStream.isOnPreview) genericStream.startPreview(surfaceView) + } + + override fun surfaceChanged( + holder: SurfaceHolder, + format: Int, + width: Int, + height: Int + ) { + Logger.d(TAG, "surfaceChanged: width = $width; height = $height") + genericStream.getGlInterface().setPreviewResolution(width, height) +// genericStream.getGlInterface().setPreviewResolution(height, width) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + if (genericStream.isOnPreview) genericStream.stopPreview() + } + + }) + + bStartStop.setOnClickListener { + if (!genericStream.isStreaming) { + genericStream.startStream(streamUrl.text.toString()) + bStartStop.setImageResource(R.drawable.ic_live_stop) + } else { + genericStream.stopStream() + bStartStop.setImageResource(R.drawable.ic_live_start) + } + } + + bRecord.setOnClickListener { +// toast("Resolution changed!") + + if (mCurResolution == Resolution._1080P) { + mCurResolution = Resolution._720P + width = 960 + height = 720 + vBitrate = 2000 * 1000 + bRecord.setImageResource(R.drawable.ic_resolution_720) + genericStream.setVideoResolution(width, height) + genericStream.setVideoBitRate(vBitrate) + if (genericStream.isStreaming) { + genericStream.stopStream() + bStartStop.setImageResource(R.drawable.ic_live_start) + } + } else { + mCurResolution = Resolution._1080P + width = 1440 + height = 1080 + vBitrate = 2500 * 1000 + bRecord.setImageResource(R.drawable.ic_resolution_1080) + genericStream.setVideoResolution(width, height) + genericStream.setVideoBitRate(vBitrate) + if (genericStream.isStreaming) { + genericStream.stopStream() + bStartStop.setImageResource(R.drawable.ic_live_start) + } + } + when (val source = genericStream.videoSource) { + is Camera2Source -> source.changeResolution() + } + +// if (!genericStream.isRecording) { +// val folder = PathUtils.getRecordPath() +// if (!folder.exists()) folder.mkdir() +// val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) +// recordPath = "${folder.absolutePath}/${sdf.format(Date())}.mp4" +// genericStream.startRecord(recordPath) { status -> +// if (status == RecordController.Status.RECORDING) { +// bRecord.setImageResource(R.drawable.stop_icon) +// } +// } +// bRecord.setImageResource(R.drawable.pause_icon) +// } else { +// genericStream.stopRecord() +// bRecord.setImageResource(R.drawable.record_icon) +// PathUtils.updateGallery(requireContext(), recordPath) +// } + } + + bSwitchCamera.setOnClickListener { + when (val source = genericStream.videoSource) { + is Camera1Source -> source.switchCamera() + is Camera2Source -> source.switchCamera() + is CameraXSource -> source.switchCamera() + } + } + return view + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun handleMessageEvent(event: BroadcastBackPressedEvent) { + if (genericStream.isStreaming) { + genericStream.stopStream() + bStartStop.setImageResource(R.drawable.ic_live_start) + } else { + (requireActivity() as RotationActivity).handleBackEvent() + } + } + + fun setOrientationMode(isVertical: Boolean) { + val wasOnPreview = genericStream.isOnPreview + Logger.d(TAG, "setOrientationMode: isVertical = $isVertical, wasOnPreview = $wasOnPreview") + genericStream.release() + rotation = if (isVertical) 90 else 0 + prepare() + if (wasOnPreview) genericStream.startPreview(surfaceView) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Logger.d(TAG, "onCreate: ") + if (EventBus.getDefault().isRegistered(this).not()) { + EventBus.getDefault().register(this) + } + prepare() + genericStream.getStreamClient().setReTries(10) + } + + private fun prepare() { + val prepared = try { + genericStream.prepareVideo(width, height, vBitrate, rotation = rotation) + && genericStream.prepareAudio(sampleRate, isStereo, aBitrate) + } catch (e: IllegalArgumentException) { + false + } + if (!prepared) { +// toast("Audio or Video configuration failed") + Logger.d(TAG, "prepare: Audio or Video configuration failed") + activity?.finish() + } + } + + override fun onDestroy() { + super.onDestroy() + genericStream.release() + if (EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().unregister(this) + } + } + + override fun onConnectionStarted(url: String) { + } + + override fun onConnectionSuccess() { + toast("Connected") + } + + override fun onConnectionFailed(reason: String) { + if (genericStream.getStreamClient().reTry(5000, reason, null)) { +// toast("Retry") + } else { + genericStream.stopStream() + bStartStop.setImageResource(R.drawable.ic_live_start) +// toast("Failed: $reason") + } + } + + override fun onNewBitrate(bitrate: Long) { + bitrateAdapter.adaptBitrate(bitrate, genericStream.getStreamClient().hasCongestion()) + txtBitrate.text = String.format(Locale.getDefault(), "%.1f mb/s", bitrate / 1000_000f) + } + + override fun onDisconnect() { + txtBitrate.text = String() + toast("Disconnected") + } + + override fun onAuthError() { + genericStream.stopStream() + bStartStop.setImageResource(R.drawable.ic_live_start) +// toast("Auth error") + } + + override fun onAuthSuccess() { +// toast("Auth success") + } } \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/LiveFragment.kt b/app/src/main/java/com/pedro/streamer/rotation/LiveFragment.kt new file mode 100644 index 000000000..a7c3d206f --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/LiveFragment.kt @@ -0,0 +1,450 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.streamer.rotation + +import android.annotation.SuppressLint +import android.graphics.drawable.Icon +import android.os.Build +import android.os.Bundle +import android.util.SparseIntArray +import android.view.LayoutInflater +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.pedro.common.ConnectChecker +import com.pedro.encoder.input.sources.video.Camera1Source +import com.pedro.encoder.input.sources.video.Camera2Source +import com.pedro.extrasources.CameraXSource +import com.pedro.library.generic.GenericStream +import com.pedro.library.util.BitrateAdapter +import com.pedro.streamer.R +import com.pedro.streamer.rotation.annotation.CameraMode +import com.pedro.streamer.rotation.annotation.IconState +import com.pedro.streamer.rotation.annotation.SettingType +import com.pedro.streamer.rotation.bean.IconInfo +import com.pedro.streamer.rotation.custom.LiveSettingsAdapter +import com.pedro.streamer.rotation.custom.LiveSettingsView +import com.pedro.streamer.rotation.eventbus.BroadcastBackPressedEvent +import com.pedro.streamer.utils.Logger +import com.pedro.streamer.utils.toast +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.util.Locale +import com.pedro.streamer.rotation.custom.OnLiveSettingsListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Example code to stream using StreamBase. This is the recommend way to use the library. + * Necessary API 21+ + * This mode allow you stream using custom Video/Audio sources, attach a preview or not dynamically, support device rotation, etc. + * + * Check Menu to use filters, video and audio sources, and orientation + * + * Orientation horizontal (by default) means that you want stream with vertical resolution + * (with = 640, height = 480 and rotation = 0) The stream/record result will be 640x480 resolution + * + * Orientation vertical means that you want stream with vertical resolution + * (with = 640, height = 480 and rotation = 90) The stream/record result will be 480x640 resolution + * + * More documentation see: + * [com.pedro.library.base.StreamBase] + * Support RTMP, RTSP and SRT with commons features + * [com.pedro.library.generic.GenericStream] + * Support RTSP with all RTSP features + * [com.pedro.library.rtsp.RtspStream] + * Support RTMP with all RTMP features + * [com.pedro.library.rtmp.RtmpStream] + * Support SRT with all SRT features + * [com.pedro.library.srt.SrtStream] + */ +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +class LiveFragment: Fragment(), ConnectChecker { + companion object { + fun getInstance(): LiveFragment = LiveFragment() + private const val TAG = "CameraFragment" + } + + enum class Resolution { + _1080P, _720P + } + + val genericStream: GenericStream by lazy { + GenericStream(requireContext(), this).apply { + getGlInterface().autoHandleOrientation = true + getStreamClient().setBitrateExponentialFactor(0.5f) + } + } + private lateinit var surfaceView: SurfaceView + private lateinit var liveStartStop: ImageView + private lateinit var bitrateText: TextView + private lateinit var recyclerView: RecyclerView + private var liveSettingView: LiveSettingsView? = null + // private val width = 640 +// private val height = 480 + private var width = 1440 + private var height = 1080 + + // private val vBitrate = 1200 * 1000 + private var vBitrate = 2500 * 1000 + private var rotation = 0 + private val sampleRate = 32000 + private val isStereo = true + private val aBitrate = 128 * 1000 + private var recordPath = "" + private var mCurResolution = Resolution._1080P + private var liveSettings: List? = null + private val liveSettingsAdapter: LiveSettingsAdapter by lazy { LiveSettingsAdapter( + onIconClick = { handleSettingIconClick(it) } + ) } + private var topLayout: FrameLayout? = null + //Bitrate adapter used to change the bitrate on fly depend of the bandwidth. + private val bitrateAdapter = BitrateAdapter { + genericStream.setVideoBitrateOnFly(it) + }.apply { + setMaxBitrate(vBitrate + aBitrate) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Logger.d(TAG, "onCreate: ") + if (EventBus.getDefault().isRegistered(this).not()) { + EventBus.getDefault().register(this) + } + prepare() + genericStream.getStreamClient().setReTries(10) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_live, container, false) + liveStartStop = view.findViewById(R.id.live_start_stop) + val liveRecord = view.findViewById(R.id.live_record) + val liveSwitchLens = view.findViewById(R.id.live_switch_lens) + recyclerView = view.findViewById(R.id.live_recycler_view) + recyclerView.adapter = liveSettingsAdapter.also { it.submitList(liveSettings) } + bitrateText = view.findViewById(R.id.live_bitrate) + surfaceView = view.findViewById(R.id.live_surface_view) + (requireActivity() as? RotationActivity)?.let { + surfaceView.setOnTouchListener(it) + } + topLayout = view.findViewById(R.id.top_layout) + showLiveSettingsDialog() + + surfaceView.holder.addCallback(object : SurfaceHolder.Callback2 { + override fun surfaceCreated(holder: SurfaceHolder) { + Logger.d(TAG, "surfaceCreated: ") + holder.setKeepScreenOn(true) + if (!genericStream.isOnPreview) genericStream.startPreview(surfaceView) + } + + override fun surfaceChanged( + holder: SurfaceHolder, + format: Int, + width: Int, + height: Int + ) { + Logger.d(TAG, "surfaceChanged: width = $width; height = $height") + genericStream.getGlInterface().setPreviewResolution(width, height) +// genericStream.getGlInterface().setPreviewResolution(height, width) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + Logger.d(TAG, "surfaceDestroyed: ") + if (genericStream.isOnPreview) genericStream.stopPreview() + } + + override fun surfaceRedrawNeeded(holder: SurfaceHolder) { + Logger.d(TAG, "surfaceRedrawNeeded: ") + } + }) + + liveStartStop.setOnClickListener { + Logger.d(TAG, "onCreateView: liveStartStop clicked") + if (!genericStream.isStreaming) { + genericStream.startStream("streamUrl.text.toString()") + liveStartStop.setImageResource(R.drawable.ic_live_stop) + } else { + genericStream.stopStream() + liveStartStop.setImageResource(R.drawable.ic_live_start) + } + } + + liveRecord.setOnClickListener { + Logger.d(TAG, "onCreateView: liveRecord clicked") + if (mCurResolution == Resolution._1080P) { + mCurResolution = Resolution._720P + width = 960 + height = 720 + vBitrate = 2000 * 1000 + liveRecord.setImageResource(R.drawable.ic_resolution_720) + genericStream.setVideoResolution(width, height) + genericStream.setVideoBitRate(vBitrate) + if (genericStream.isStreaming) { + genericStream.stopStream() + liveStartStop.setImageResource(R.drawable.ic_live_start) + } + } else { + mCurResolution = Resolution._1080P + width = 1440 + height = 1080 + vBitrate = 2500 * 1000 + liveRecord.setImageResource(R.drawable.ic_resolution_1080) + genericStream.setVideoResolution(width, height) + genericStream.setVideoBitRate(vBitrate) + if (genericStream.isStreaming) { + genericStream.stopStream() + liveStartStop.setImageResource(R.drawable.ic_live_start) + } + } + when (val source = genericStream.videoSource) { + is Camera2Source -> source.changeResolution() + } + +// if (!genericStream.isRecording) { +// val folder = PathUtils.getRecordPath() +// if (!folder.exists()) folder.mkdir() +// val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) +// recordPath = "${folder.absolutePath}/${sdf.format(Date())}.mp4" +// genericStream.startRecord(recordPath) { status -> +// if (status == RecordController.Status.RECORDING) { +// bRecord.setImageResource(R.drawable.stop_icon) +// } +// } +// bRecord.setImageResource(R.drawable.pause_icon) +// } else { +// genericStream.stopRecord() +// bRecord.setImageResource(R.drawable.record_icon) +// PathUtils.updateGallery(requireContext(), recordPath) +// } + } + + liveSwitchLens.setOnClickListener { + Logger.d(TAG, "onCreateView: liveSwitchLens clicked") + when (val source = genericStream.videoSource) { + is Camera1Source -> source.switchCamera() + is Camera2Source -> source.switchCamera() + is CameraXSource -> source.switchCamera() + } + } + return view + } + + override fun onDestroy() { + super.onDestroy() + liveSettingView?.let { + it.setLiveSettingsListener(null) + topLayout?.removeView(it) + liveSettingView = null + } + genericStream.release() + if (EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().unregister(this) + } + } + + private fun showLiveSettingsDialog() { + if(liveSettingView != null && liveSettingView?.parent != null){ + liveSettingView?.let { + it.isVisible = true + it.bringToFront() + it.requestLayout() + } + return + } + val v = LiveSettingsView(requireContext()) + v.setLiveSettingsListener(object : OnLiveSettingsListener { + override fun onScanCodeClicked(type: Int) { + showScanCodeView() + } + + override fun onMobileNetworkChecked(isChecked: Boolean) { + + } + }) + val lp = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + topLayout?.addView(v, lp) + v.bringToFront() + v.requestLayout() + } + liveSettingView = v + } + + private fun hideLiveSettingsDialog() { + liveSettingView?.let { + it.isVisible = false + it.clearEditTextFocus() + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun handleMessageEvent(event: BroadcastBackPressedEvent) { + if (genericStream.isStreaming) { + genericStream.stopStream() + liveStartStop.setImageResource(R.drawable.ic_live_start) + } else { + (requireActivity() as CameraActivity).handleBackEvent() + } + } + + fun setOrientationMode(isVertical: Boolean) { + val wasOnPreview = genericStream.isOnPreview + Logger.d(TAG, "setOrientationMode: isVertical = $isVertical, wasOnPreview = $wasOnPreview") + genericStream.release() + rotation = if (isVertical) 90 else 0 + prepare() + if (wasOnPreview) genericStream.startPreview(surfaceView) + } + + private fun prepare() { + Logger.d(TAG, "prepare: ") + prepareLiveSettings() + val prepared = try { + genericStream.prepareVideo(width, height, vBitrate, rotation = rotation) + && genericStream.prepareAudio(sampleRate, isStereo, aBitrate) + } catch (e: IllegalArgumentException) { + false + } + if (!prepared) { + Logger.d(TAG, "prepare: Audio or Video configuration failed") + activity?.finish() + } + } + + private fun prepareLiveSettings(){ + @SettingType + val settingTypes = listOf( + SettingType.LIVE, + SettingType.RESOLUTION, + SettingType.MICROPHONE + ) + liveSettings = settingTypes.map { type -> + val icons = getIconsBySettingType(type) + IconInfo(CameraMode.LIVE, type, icons.keyAt(0), icons) + } + } + + private fun getIconsBySettingType(@SettingType settingType: Int): SparseIntArray { + return when (settingType) { + SettingType.LIVE -> SparseIntArray().apply { + put(IconState.OFF, R.drawable.ic_live_setting_off) + put(IconState.ON, R.drawable.ic_live_setting_on) + } + SettingType.RESOLUTION -> SparseIntArray().apply { + put(IconState.ON, R.drawable.ic_resolution_720) + put(IconState.ON, R.drawable.ic_resolution_1080) + } + SettingType.MICROPHONE -> SparseIntArray().apply { + put(IconState.ON, R.drawable.ic_microphone_on) + put(IconState.OFF, R.drawable.ic_microphone_off) + } + else -> SparseIntArray() + } + } + + private fun handleSettingIconClick(iconInfo: IconInfo){ + when (iconInfo.mode) { + CameraMode.LIVE -> { + when (iconInfo.type) { + SettingType.LIVE -> { + when (iconInfo.state) { + IconState.ON -> { + showLiveSettingsDialog() + } + IconState.OFF -> { + hideLiveSettingsDialog() + } + } + } + SettingType.RESOLUTION -> { + + } + SettingType.MICROPHONE -> { + when (iconInfo.state) { + IconState.ON -> { + + } + IconState.OFF -> { + + } + } + } + } + } + } + } + + + override fun onConnectionStarted(url: String) { + } + + override fun onConnectionSuccess() { + toast("Connected") + } + + override fun onConnectionFailed(reason: String) { + if (genericStream.getStreamClient().reTry(5000, reason, null)) { +// toast("Retry") + } else { + genericStream.stopStream() + liveStartStop.setImageResource(R.drawable.ic_live_start) +// toast("Failed: $reason") + } + } + + override fun onNewBitrate(bitrate: Long) { + bitrateAdapter.adaptBitrate(bitrate, genericStream.getStreamClient().hasCongestion()) + bitrateText.text = String.format(Locale.getDefault(), "%.1f mb/s", bitrate / 1000_000f) + } + + override fun onDisconnect() { + bitrateText.text = String() + toast("Disconnected") + } + + override fun onAuthError() { + genericStream.stopStream() + liveStartStop.setImageResource(R.drawable.ic_live_start) +// toast("Auth error") + } + + override fun onAuthSuccess() { +// toast("Auth success") + } + + private fun showScanCodeView() { + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/RotationActivity.kt b/app/src/main/java/com/pedro/streamer/rotation/RotationActivity.kt index fdaeea9b2..8a27f4dca 100644 --- a/app/src/main/java/com/pedro/streamer/rotation/RotationActivity.kt +++ b/app/src/main/java/com/pedro/streamer/rotation/RotationActivity.kt @@ -17,18 +17,29 @@ package com.pedro.streamer.rotation import android.annotation.SuppressLint -import android.graphics.Bitmap +import android.content.Intent +import android.content.pm.PackageManager import android.graphics.BitmapFactory +import android.media.audiofx.Virtualizer +import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.Settings +import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.View.OnTouchListener +import android.window.OnBackInvokedDispatcher +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.graphics.scale +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import com.pedro.encoder.input.sources.audio.MicrophoneSource import com.pedro.encoder.input.sources.video.BitmapSource import com.pedro.encoder.input.sources.video.BufferVideoSource @@ -37,124 +48,317 @@ import com.pedro.encoder.input.sources.video.Camera2Source import com.pedro.extrasources.CameraUvcSource import com.pedro.extrasources.CameraXSource import com.pedro.streamer.R +import com.pedro.streamer.rotation.eventbus.BroadcastBackPressedEvent +import com.pedro.streamer.rotation.topmethod.onBackPressedListener import com.pedro.streamer.utils.FilterMenu -import com.pedro.streamer.utils.fitAppPadding +import com.pedro.streamer.utils.Logger import com.pedro.streamer.utils.toast import com.pedro.streamer.utils.updateMenuColor -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - +import org.greenrobot.eventbus.EventBus /** * Created by pedro on 22/3/22. */ @RequiresApi(Build.VERSION_CODES.LOLLIPOP) class RotationActivity : AppCompatActivity(), OnTouchListener { + companion object { + private const val TAG = "RotationActivity" + private const val EXIT_TIME_INTERVAL = 2000 + } - private val cameraFragment = CameraFragment.getInstance() - private val filterMenu: FilterMenu by lazy { FilterMenu(this) } - private var currentVideoSource: MenuItem? = null - private var currentAudioSource: MenuItem? = null - private var currentOrientation: MenuItem? = null - private var currentFilter: MenuItem? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.rotation_activity) - fitAppPadding() - supportFragmentManager.beginTransaction().add(R.id.container, cameraFragment).commit() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.rotation_menu, menu) - val defaultVideoSource = menu.findItem(R.id.video_source_camera2) - val defaultAudioSource = menu.findItem(R.id.audio_source_microphone) - val defaultOrientation = menu.findItem(R.id.orientation_horizontal) - val defaultFilter = menu.findItem(R.id.no_filter) - currentVideoSource = defaultVideoSource.updateMenuColor(this, currentVideoSource) - currentAudioSource = defaultAudioSource.updateMenuColor(this, currentAudioSource) - currentOrientation = defaultOrientation.updateMenuColor(this, currentOrientation) - currentFilter = defaultFilter.updateMenuColor(this, currentFilter) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - try { - when (item.itemId) { - R.id.video_source_camera1 -> { - currentVideoSource = item.updateMenuColor(this, currentVideoSource) - cameraFragment.genericStream.changeVideoSource(Camera1Source(applicationContext)) + private val cameraFragment = CameraFragment.getInstance() + private val filterMenu: FilterMenu by lazy { FilterMenu(this) } + private var currentVideoSource: MenuItem? = null + private var currentAudioSource: MenuItem? = null + private var currentOrientation: MenuItem? = null + private var currentFilter: MenuItem? = null + private var currentPlatform: MenuItem? = null + private var mClickTime: Long = 0 + private var requestPermissionLauncher: ActivityResultLauncher>? = null + private var hasCheckPermission: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.rotation_activity) + requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()){ result: Map -> + handlePermissionResults(result) } - R.id.video_source_camera2 -> { - currentVideoSource = item.updateMenuColor(this, currentVideoSource) - cameraFragment.genericStream.changeVideoSource(Camera2Source(applicationContext)) + checkAndRequestPermissions() + initBackEventListener() + } + + private fun buildRequiredPermissions(): Array { + val perms = mutableListOf() + perms.add(android.Manifest.permission.CAMERA) + perms.add(android.Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + perms.add(android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + perms.add(android.Manifest.permission.READ_MEDIA_IMAGES) + perms.add(android.Manifest.permission.READ_MEDIA_VIDEO) + } else { + perms.add(android.Manifest.permission.READ_EXTERNAL_STORAGE) + perms.add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) } - R.id.video_source_camerax -> { - currentVideoSource = item.updateMenuColor(this, currentVideoSource) - cameraFragment.genericStream.changeVideoSource(CameraXSource(applicationContext)) + return perms.toTypedArray() + } + + private fun checkAndRequestPermissions() { + hasCheckPermission = true + val required = buildRequiredPermissions() + val notGranted = required.filter { perm -> + ContextCompat.checkSelfPermission(this, perm) != PackageManager.PERMISSION_GRANTED } - R.id.video_source_camera_uvc -> { - currentVideoSource = item.updateMenuColor(this, currentVideoSource) - cameraFragment.genericStream.changeVideoSource(CameraUvcSource()) + if(notGranted.isEmpty()){ + onAllPermissionGranted() + }else{ + requestPermissionLauncher?.launch(notGranted.toTypedArray()) } - R.id.video_source_bitmap -> { - currentVideoSource = item.updateMenuColor(this, currentVideoSource) - val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) - cameraFragment.genericStream.changeVideoSource(BitmapSource(bitmap)) + } + + private fun handlePermissionResults(results: Map) { + val denied = results.filter { !it.value }.keys + if(denied.isEmpty()){ + onAllPermissionGranted() + return + } + // 检查是否有“永久拒绝”(即用户勾选了 Don't ask again / 不再询问) + val permanentlyDenied = denied.filter { perm -> + isPermissionPermanentlyDenied(perm) } - R.id.video_source_buffer -> { - currentVideoSource = item.updateMenuColor(this, currentVideoSource) - val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) - val data = bitmapToRgba(bitmap.scale(cameraFragment.width, cameraFragment.height)) - val source = BufferVideoSource(BufferVideoSource.Format.ARGB, cameraFragment.vBitrate) - cameraFragment.genericStream.changeVideoSource(source) - CoroutineScope(Dispatchers.IO).launch { - while (cameraFragment.genericStream.videoSource is BufferVideoSource) { - source.setBuffer(data.clone()) - delay(1000 / 30) + if(permanentlyDenied.isNotEmpty()){ + // 有永久拒绝 — 强制引导到设置页 + showPermissionDeniedPermanentlyDialog(permanentlyDenied) + }else{ + // 只是普通拒绝 — 给用户一个明确说明和再次请求的机会或退出 + showPermissionRationaleDialog(denied) + } + } + + /** + * 判断某个权限是否被“永久拒绝”(拒绝并不再询问) + */ + private fun isPermissionPermanentlyDenied(permission: String): Boolean { + val denied = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_DENIED + // shouldShowRequestPermissionRationale -> false 表示:要么从未请求过,要么永久拒绝 + val shouldShow = ActivityCompat.shouldShowRequestPermissionRationale(this, permission) + return denied && !shouldShow + } + + private fun onAllPermissionGranted(){ + Logger.d(TAG, "onAllPermissionGranted: ") + supportFragmentManager.beginTransaction().add(R.id.container, cameraFragment).commit() + } + + private fun showPermissionRationaleDialog(deniedPermissions: Set) { + Logger.d(TAG, "showPermissionRationaleDialog: deniedPermissions = $deniedPermissions") + val message = buildRationaleMessage(deniedPermissions) + AlertDialog.Builder(this) + .setTitle("需要授权以继续使用") + .setMessage(message) + .setCancelable(false) + .setPositiveButton("重新授权") { _, _ -> + requestPermissionLauncher?.launch(deniedPermissions.toTypedArray()) + } + .setNegativeButton("退出应用") { _, _ -> + finishAffinity() + } + .show() + } + + private fun showPermissionDeniedPermanentlyDialog(permanentlyDenied: List) { + Logger.d(TAG, "showPermissionDeniedPermanentlyDialog: permanentlyDenied = $permanentlyDenied") + val message = buildRationaleMessage(permanentlyDenied.toSet()) + "\n\n请在应用设置中手动开启权限,否则无法使用本应用。" + AlertDialog.Builder(this) + .setTitle("权限被禁用") + .setMessage(message) + .setCancelable(false) + .setPositiveButton("打开设置") { _, _ -> + openAppSettings() + } + .setNegativeButton("退出应用") { _, _ -> + finishAffinity() + } + .show() + } + + private fun openAppSettings() { + hasCheckPermission = false + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null) + ) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } + + private fun buildRationaleMessage(deniedPermissions: Set): String { + val human = deniedPermissions.map { perm -> + when (perm) { + android.Manifest.permission.CAMERA -> "相机(拍摄/扫码)" + android.Manifest.permission.RECORD_AUDIO -> "麦克风(语音/录音)" + android.Manifest.permission.READ_MEDIA_IMAGES, + android.Manifest.permission.READ_MEDIA_VIDEO, + android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED, + android.Manifest.permission.READ_EXTERNAL_STORAGE, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE -> "相册/媒体(读取照片)" + else -> perm } - } } - R.id.audio_source_microphone -> { - currentAudioSource = item.updateMenuColor(this, currentAudioSource) - cameraFragment.genericStream.changeAudioSource(MicrophoneSource()) + return "应用需要以下权限:\n• ${human.joinToString("\n• ")}\n以正常运行。" + } + + override fun onResume() { + super.onResume() + // 用户可能从设置页回来,这里再次检查权限 + if(!hasCheckPermission){ + checkAndRequestPermissionsIfNeeded() } - R.id.orientation_horizontal -> { - currentOrientation = item.updateMenuColor(this, currentOrientation) - cameraFragment.setOrientationMode(false) + } + + private fun checkAndRequestPermissionsIfNeeded() { + val required = buildRequiredPermissions() + val notGranted = required.filter { perm -> + ContextCompat.checkSelfPermission(this, perm) != PackageManager.PERMISSION_GRANTED + } + if(notGranted.isEmpty()){ + onAllPermissionGranted() + }else{ + AlertDialog.Builder(this) + .setTitle("权限未就绪") + .setMessage("必要权限仍然未授权,应用无法继续运行。") + .setCancelable(false) + .setPositiveButton("退出应用") { _, _ -> + finishAffinity() + } + .show() } - R.id.orientation_vertical -> { - currentOrientation = item.updateMenuColor(this, currentOrientation) - cameraFragment.setOrientationMode(true) + } + + private fun initBackEventListener() { + onBackPressedListener(true) { + EventBus.getDefault().post(BroadcastBackPressedEvent()) } - else -> { - val result = filterMenu.onOptionsItemSelected(item, cameraFragment.genericStream.getGlInterface()) - if (result) currentFilter = item.updateMenuColor(this, currentFilter) - return result + } + + fun handleBackEvent() { + Logger.d(TAG, "handleBackEvent: ") + if ((System.currentTimeMillis() - mClickTime) > EXIT_TIME_INTERVAL) { + toast("Press again to exit app") + mClickTime = System.currentTimeMillis() + } else { + finish() } - } - } catch (e: IllegalArgumentException) { - toast("Change source error: ${e.message}") - } - return super.onOptionsItemSelected(item) - } - - private fun bitmapToRgba(bitmap: Bitmap): IntArray { - require(bitmap.config == Bitmap.Config.ARGB_8888) { "Bitmap must be in ARGB_8888 format" } - val pixels = IntArray(bitmap.width * bitmap.height) - bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - return pixels - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(view: View, motionEvent: MotionEvent): Boolean { - if (filterMenu.spriteGestureController.spriteTouched(view, motionEvent)) { - filterMenu.spriteGestureController.moveSprite(view, motionEvent) - filterMenu.spriteGestureController.scaleSprite(motionEvent) - return true - } - return false - } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rotation_menu, menu) + val defaultVideoSource = menu.findItem(R.id.video_source_camera2) + val defaultAudioSource = menu.findItem(R.id.audio_source_microphone) + val defaultOrientation = menu.findItem(R.id.orientation_horizontal) + val defaultFilter = menu.findItem(R.id.no_filter) + val defaultPlatform = menu.findItem(R.id.platform_youtube) + currentVideoSource = defaultVideoSource.updateMenuColor(this, currentVideoSource) + currentAudioSource = defaultAudioSource.updateMenuColor(this, currentAudioSource) + currentOrientation = defaultOrientation.updateMenuColor(this, currentOrientation) + currentFilter = defaultFilter.updateMenuColor(this, currentFilter) + currentPlatform = defaultPlatform.updateMenuColor(this, currentPlatform) + return true + } + +// override fun onBackPressed() { +// super.onBackPressed() +// Logger.d(TAG, "onBackPressed: ") +// } + +// override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { +// if(keyCode == KeyEvent.KEYCODE_BACK){ +// Logger.d(TAG, "onKeyDown: ") +// +// } +// return super.onKeyDown(keyCode, event) +// } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + try { + when (item.itemId) { + R.id.video_source_camera1 -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + cameraFragment.genericStream.changeVideoSource(Camera1Source(applicationContext)) + } + + R.id.video_source_camera2 -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + cameraFragment.genericStream.changeVideoSource(Camera2Source(applicationContext)) + } + + R.id.video_source_camerax -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + cameraFragment.genericStream.changeVideoSource(CameraXSource(applicationContext)) + } + + R.id.video_source_camera_uvc -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + cameraFragment.genericStream.changeVideoSource(CameraUvcSource()) + } + + R.id.video_source_bitmap -> { + currentVideoSource = item.updateMenuColor(this, currentVideoSource) + val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) + cameraFragment.genericStream.changeVideoSource(BitmapSource(bitmap)) + } + + R.id.audio_source_microphone -> { + currentAudioSource = item.updateMenuColor(this, currentAudioSource) + cameraFragment.genericStream.changeAudioSource(MicrophoneSource()) + } + + R.id.orientation_horizontal -> { + currentOrientation = item.updateMenuColor(this, currentOrientation) + cameraFragment.setOrientationMode(false) + } + + R.id.orientation_vertical -> { + currentOrientation = item.updateMenuColor(this, currentOrientation) + cameraFragment.setOrientationMode(true) + } + + R.id.platform_huya -> { + currentPlatform = item.updateMenuColor(this, currentPlatform) + cameraFragment.streamUrl.setText(R.string.stream_url_huya) + } + + R.id.platform_tiktok -> { + currentPlatform = item.updateMenuColor(this, currentPlatform) + cameraFragment.streamUrl.setText(R.string.stream_url_tiktok) + } + + R.id.platform_youtube -> { + currentPlatform = item.updateMenuColor(this, currentPlatform) + cameraFragment.streamUrl.setText(R.string.stream_url_youtube) + } + + else -> { + val result = filterMenu.onOptionsItemSelected( + item, + cameraFragment.genericStream.getGlInterface() + ) + if (result) currentFilter = item.updateMenuColor(this, currentFilter) + return result + } + } + } catch (e: IllegalArgumentException) { +// toast("Change source error: ${e.message}") + } + return super.onOptionsItemSelected(item) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(view: View, motionEvent: MotionEvent): Boolean { + if (filterMenu.spriteGestureController.spriteTouched(view, motionEvent)) { + filterMenu.spriteGestureController.moveSprite(view, motionEvent) + filterMenu.spriteGestureController.scaleSprite(motionEvent) + return true + } + return false + } } \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/annotation/CameraMode.kt b/app/src/main/java/com/pedro/streamer/rotation/annotation/CameraMode.kt new file mode 100644 index 000000000..36c79c596 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/annotation/CameraMode.kt @@ -0,0 +1,13 @@ +package com.pedro.streamer.rotation.annotation + +import androidx.annotation.IntDef + +@IntDef(CameraMode.PHOTO, CameraMode.VIDEO, CameraMode.LIVE) +@Retention(AnnotationRetention.SOURCE) +annotation class CameraMode { + companion object { + const val PHOTO = 0 + const val VIDEO = 1 + const val LIVE = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/annotation/IconState.kt b/app/src/main/java/com/pedro/streamer/rotation/annotation/IconState.kt new file mode 100644 index 000000000..b6c02130d --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/annotation/IconState.kt @@ -0,0 +1,16 @@ +package com.pedro.streamer.rotation.annotation + +import androidx.annotation.IntDef + +@IntDef(IconState.SHOW, IconState.GONE, IconState.ON, IconState.OFF, IconState.ENABLE, IconState.DISABLE) +@Retention(AnnotationRetention.SOURCE) +annotation class IconState { + companion object { + const val SHOW = 0x00 + const val GONE = 0x01 + const val ON = 0x02 + const val OFF = 0x03 + const val ENABLE = 0x04 + const val DISABLE = 0x05 + } +} diff --git a/app/src/main/java/com/pedro/streamer/rotation/annotation/SettingType.kt b/app/src/main/java/com/pedro/streamer/rotation/annotation/SettingType.kt new file mode 100644 index 000000000..d107d09a6 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/annotation/SettingType.kt @@ -0,0 +1,10 @@ +package com.pedro.streamer.rotation.annotation + +@Retention(AnnotationRetention.SOURCE) +annotation class SettingType { + companion object { + const val LIVE = 0x00 + const val RESOLUTION = 0x01 + const val MICROPHONE = 0x02 + } +} diff --git a/app/src/main/java/com/pedro/streamer/rotation/bean/IconInfo.kt b/app/src/main/java/com/pedro/streamer/rotation/bean/IconInfo.kt new file mode 100644 index 000000000..27c5a0c4a --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/bean/IconInfo.kt @@ -0,0 +1,17 @@ +package com.pedro.streamer.rotation.bean + +import android.util.SparseIntArray +import androidx.annotation.IntDef +import com.pedro.streamer.rotation.annotation.CameraMode +import com.pedro.streamer.rotation.annotation.IconState +import com.pedro.streamer.rotation.annotation.SettingType + +data class IconInfo( + @CameraMode + val mode: Int, + @SettingType + val type: Int, + @IconState + var state: Int, + val images: SparseIntArray, +) diff --git a/app/src/main/java/com/pedro/streamer/rotation/custom/LiveSettingsAdapter.kt b/app/src/main/java/com/pedro/streamer/rotation/custom/LiveSettingsAdapter.kt new file mode 100644 index 000000000..01202e658 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/custom/LiveSettingsAdapter.kt @@ -0,0 +1,69 @@ +package com.pedro.streamer.rotation.custom + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.pedro.streamer.R +import com.pedro.streamer.rotation.bean.IconInfo +import com.pedro.streamer.utils.Logger +import android.widget.ImageView +import com.pedro.streamer.rotation.annotation.IconState + +class LiveSettingsAdapter( + private val onIconClick: (IconInfo) -> Unit +): ListAdapter(DIFF) { + companion object { + private const val TAG = "LiveSettingsAdapter" + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: IconInfo, + newItem: IconInfo + ): Boolean { + return oldItem.type == newItem.type && oldItem.state == newItem.state + } + + override fun areContentsTheSame( + oldItem: IconInfo, + newItem: IconInfo + ): Boolean { + return oldItem == newItem + } + } + } + + inner class IconVH(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val imageView = itemView.findViewById(R.id.item_live_setting_icon) + + fun bind(icon: IconInfo) { + Logger.d(TAG, "bind: icon = $icon") + imageView.setImageResource(icon.images[icon.state]) + imageView.setOnClickListener { + icon.state = if(icon.state == IconState.ON) IconState.OFF else IconState.ON + imageView.setImageResource(icon.images[icon.state]) + onIconClick(icon) + } + } + fun onRecycled() { + Logger.d(TAG, "onRecycled: hashcode = ${hashCode()}") + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): IconVH { + val v = LayoutInflater.from(parent.context).inflate(R.layout.item_live_setting, parent, false) + return IconVH(v) + } + + override fun onBindViewHolder(holder: IconVH, position: Int) { + Logger.d(TAG, "onBindViewHolder: position = $position") + holder.bind(getItem(position)) + } + + override fun onViewRecycled(holder: IconVH) { + super.onViewRecycled(holder) + Logger.d(TAG, "onViewRecycled: hashcode = ${holder.hashCode()}") + holder.onRecycled() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/custom/LiveSettingsView.kt b/app/src/main/java/com/pedro/streamer/rotation/custom/LiveSettingsView.kt new file mode 100644 index 000000000..bec3e039a --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/custom/LiveSettingsView.kt @@ -0,0 +1,133 @@ +package com.pedro.streamer.rotation.custom + +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import androidx.annotation.IntDef +import androidx.constraintlayout.widget.ConstraintLayout +import com.pedro.streamer.databinding.ViewLiveSettingsDialogBinding + +class LiveSettingsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), View.OnClickListener { + companion object { + private const val TAG = "LiveSettingsView" + } + + private val binding: ViewLiveSettingsDialogBinding = + ViewLiveSettingsDialogBinding.inflate(LayoutInflater.from(context), this, true) + + private var listener: OnLiveSettingsListener? = null + @ScanType + private var scanType: Int = ScanType.PATH + + @IntDef(ScanType.PATH, ScanType.CODE) + @Retention(AnnotationRetention.SOURCE) + annotation class ScanType { + companion object { + const val PATH = 0 + const val CODE = 1 + } + } + + init { + // 初始化 Switch 状态并设置点击监听 +// binding.itemLiveSwitch.isChecked = PreferencesUtils.getBoolean(context, SettingsConstant.KEY_USE_MOBILE_NETWORK) + binding.ivLiveSettingsPathScan.setOnClickListener(this) + binding.ivLiveSettingsCodeScan.setOnClickListener(this) + binding.itemLiveSwitch.setOnClickListener(this) + } + + /** + * 适配竖屏/横屏的位移(保持和你原实现一致) + */ + fun matchPortraitScreen() { + translationY = 80f + translationX = 0f + } + + fun matchLandScreen() { + translationX = 0f + translationY = 0f + } + + override fun onClick(v: View) { + when (v.id) { + binding.ivLiveSettingsPathScan.id -> { + scanType = ScanType.PATH + listener?.onScanCodeClicked(scanType) + } + binding.ivLiveSettingsCodeScan.id -> { + scanType = ScanType.CODE + listener?.onScanCodeClicked(scanType) + } + binding.itemLiveSwitch.id -> { + val isChecked = binding.itemLiveSwitch.isChecked + //PreferencesUtils.putBoolean(context, SettingsConstant.KEY_USE_MOBILE_NETWORK, isChecked) + listener?.onMobileNetworkChecked(isChecked) + } + } + } + + fun setLiveSettingsListener(l: OnLiveSettingsListener?) { + listener = l + } + + /** + * 接收二维码扫描结果并填写到对应 EditText(如果值相同则忽略) + */ + fun receiveQrScanResult(result: String) { + when (scanType) { + ScanType.PATH -> { + val current = binding.etLiveSettingsPath.text?.toString() + if (current != result) { + binding.etLiveSettingsPath.setText(result) + } + } + ScanType.CODE -> { + val current = binding.etLiveSettingsCode.text?.toString() + if (current != result) { + binding.etLiveSettingsCode.setText(result) + } + } + } + } + + /** + * 拼接 live url。会做空值保护与多余斜杠清理 + * + * 例如: + * etPath: "https://example.com/stream" + * etCode: "room1" + * -> "https://example.com/stream/room1" + */ + fun appendLiveUrl(): String { + val path = binding.etLiveSettingsPath.text?.toString()?.trim().orEmpty() + .trimEnd('/') + val code = binding.etLiveSettingsCode.text?.toString()?.trim().orEmpty() + .trimStart('/') + + return when { + path.isBlank() && code.isBlank() -> "" + path.isBlank() -> code + code.isBlank() -> path + else -> "$path/$code" + } + } + + /** + * 清除 EditText 焦点并隐藏键盘(安全地从 context 转换为 Activity) + */ + fun clearEditTextFocus() { + val activity = context as? Activity ?: return + val current = activity.currentFocus + if (current == binding.etLiveSettingsPath || current == binding.etLiveSettingsCode) { + current.clearFocus() + //PubUtils.getInstance().hideKeyboard(context, current) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/custom/OnLiveSettingsListener.kt b/app/src/main/java/com/pedro/streamer/rotation/custom/OnLiveSettingsListener.kt new file mode 100644 index 000000000..0a1e9515a --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/custom/OnLiveSettingsListener.kt @@ -0,0 +1,7 @@ +package com.pedro.streamer.rotation.custom + +interface OnLiveSettingsListener { + fun onScanCodeClicked(@LiveSettingsView.ScanType type: Int) + + fun onMobileNetworkChecked(isChecked: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/eventbus/BroadcastBackPressedEvent.kt b/app/src/main/java/com/pedro/streamer/rotation/eventbus/BroadcastBackPressedEvent.kt new file mode 100644 index 000000000..d15951726 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/eventbus/BroadcastBackPressedEvent.kt @@ -0,0 +1,4 @@ +package com.pedro.streamer.rotation.eventbus + +//用于EventBus事件 +class BroadcastBackPressedEvent {} \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/topmethod/TopMethod.kt b/app/src/main/java/com/pedro/streamer/rotation/topmethod/TopMethod.kt new file mode 100644 index 000000000..1508b9ae3 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/rotation/topmethod/TopMethod.kt @@ -0,0 +1,12 @@ +package com.pedro.streamer.rotation.topmethod + +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity + +fun AppCompatActivity.onBackPressedListener(isEnabled: Boolean, callback: () -> Unit) { + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(isEnabled) { + override fun handleOnBackPressed() { + callback() + } + }) +} \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/screen/ScreenActivity.kt b/app/src/main/java/com/pedro/streamer/screen/ScreenActivity.kt index c96d4d92d..6a0f4a0b3 100644 --- a/app/src/main/java/com/pedro/streamer/screen/ScreenActivity.kt +++ b/app/src/main/java/com/pedro/streamer/screen/ScreenActivity.kt @@ -104,9 +104,9 @@ class ScreenActivity : AppCompatActivity(), ConnectChecker { button.setImageResource(R.drawable.stream_icon) } if (screenService != null && screenService.isRecording()) { - bRecord.setImageResource(R.drawable.stop_icon) + bRecord.setImageResource(R.drawable.ic_record_stop) } else { - bRecord.setImageResource(R.drawable.record_icon) + bRecord.setImageResource(R.drawable.ic_record_start) } button.setOnClickListener { val service = ScreenService.INSTANCE @@ -123,33 +123,18 @@ class ScreenActivity : AppCompatActivity(), ConnectChecker { } } bRecord.setOnClickListener { - val service = ScreenService.INSTANCE - if (service != null) { - service.setCallback(this) - if (!service.isStreaming() && !service.isRecording()) { - action = Action.RECORD - activityResultContract.launch(service.sendIntent()) - } else toggleRecord() - } - } - } - - private fun startStream() { - button.setImageResource(R.drawable.stream_stop_icon) - val endpoint = etUrl.text.toString() - ScreenService.INSTANCE?.startStream(endpoint) - } - - private fun stopStream() { - button.setImageResource(R.drawable.stream_icon) - ScreenService.INSTANCE?.stopStream() - } - - private fun toggleRecord() { - ScreenService.INSTANCE?.toggleRecord { state -> - when (state) { - RecordController.Status.STARTED -> { - bRecord.setImageResource(R.drawable.pause_icon) + ScreenService.INSTANCE?.toggleRecord { state -> + when (state) { + RecordController.Status.STARTED -> { + bRecord.setImageResource(R.drawable.ic_record_pause) + } + RecordController.Status.STOPPED -> { + bRecord.setImageResource(R.drawable.ic_record_start) + } + RecordController.Status.RECORDING -> { + bRecord.setImageResource(R.drawable.ic_record_stop) + } + else -> {} } RecordController.Status.STOPPED -> { bRecord.setImageResource(R.drawable.record_icon) diff --git a/app/src/main/java/com/pedro/streamer/utils/Extensions.kt b/app/src/main/java/com/pedro/streamer/utils/Extensions.kt index 36e8aca1a..4c186ad0b 100644 --- a/app/src/main/java/com/pedro/streamer/utils/Extensions.kt +++ b/app/src/main/java/com/pedro/streamer/utils/Extensions.kt @@ -76,7 +76,7 @@ fun Drawable.setColorFilter(@ColorInt color: Int) { fun MenuItem.updateMenuColor(context: Context, currentItem: MenuItem?): MenuItem { currentItem?.setColor(context, R.color.black) - setColor(context, R.color.appColor) + setColor(context, R.color.orange) return this } diff --git a/app/src/main/java/com/pedro/streamer/utils/Logger.kt b/app/src/main/java/com/pedro/streamer/utils/Logger.kt new file mode 100644 index 000000000..4ad7cea47 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/utils/Logger.kt @@ -0,0 +1,73 @@ +package com.pedro.streamer.utils + +import android.util.Log +import com.pedro.streamer.BuildConfig + + +object Logger { + private const val TAG = "HUANG" + private const val ERROR = "ANDROID_ERROR:" + private const val WARN = "ANDROID_WARN:" + private const val RUNTIME_EXCEPTION = "ANDROID_RUNTIME_EXCEPTION" +// private val DEBUG = BuildConfig.DEBUG + private val DEBUG = true + + @JvmStatic + fun v(subTag: String, message: String){ + if (DEBUG) { + Log.v(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun d(subTag: String, message: String){ + if (DEBUG) { + Log.d(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun i(subTag: String, message: String){ + if (DEBUG) { + Log.i(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun w(subTag: String, message: String){ + if (DEBUG) { + Log.w(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $WARN $message") + } + } + @JvmStatic + fun e(subTag: String, message: String){ + if (DEBUG) { + Log.e(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $ERROR $message") + } + } + + @JvmStatic + fun throwRuntimeException(){ + RuntimeException(TAG).printStackTrace() + } + + @JvmStatic + fun printStackTrace(tag: String){ + //打印堆栈而不退出 + d(tag, Log.getStackTraceString(Throwable())) + + Exception("debug log").printStackTrace() + + for (i in Thread.currentThread().stackTrace){ + i(tag, i.toString()) + } + val runtimeException = RuntimeException() + runtimeException.fillInStackTrace() + + try { + i(tag, "----------------------throw NullPointerException----------------------") + throw NullPointerException() + } catch (nullPointer: NullPointerException) { + i(tag, "----------------------catch NullPointerException----------------------") + e(tag, Log.getStackTraceString(nullPointer)) + } + i(tag, "----------------------end NullPointerException ----------------------") + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_b2_black_round_8dp_70.xml b/app/src/main/res/drawable/bg_b2_black_round_8dp_70.xml new file mode 100644 index 000000000..8418f4c2a --- /dev/null +++ b/app/src/main/res/drawable/bg_b2_black_round_8dp_70.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_black20_round_2dp.xml b/app/src/main/res/drawable/bg_black20_round_2dp.xml new file mode 100644 index 000000000..cc736acfe --- /dev/null +++ b/app/src/main/res/drawable/bg_black20_round_2dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/camera_switch.xml b/app/src/main/res/drawable/camera_switch.xml new file mode 100644 index 000000000..5733c725c --- /dev/null +++ b/app/src/main/res/drawable/camera_switch.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/hohem_icon.png b/app/src/main/res/drawable/hohem_icon.png new file mode 100644 index 000000000..23e9d3cb2 Binary files /dev/null and b/app/src/main/res/drawable/hohem_icon.png differ diff --git a/app/src/main/res/drawable/ic_live_setting_off.xml b/app/src/main/res/drawable/ic_live_setting_off.xml new file mode 100644 index 000000000..e810ca4f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_live_setting_off.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_live_setting_on.xml b/app/src/main/res/drawable/ic_live_setting_on.xml new file mode 100644 index 000000000..3e15623d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_live_setting_on.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_live_start.xml b/app/src/main/res/drawable/ic_live_start.xml new file mode 100644 index 000000000..c59ef373e --- /dev/null +++ b/app/src/main/res/drawable/ic_live_start.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_live_stop.xml b/app/src/main/res/drawable/ic_live_stop.xml new file mode 100644 index 000000000..61536e114 --- /dev/null +++ b/app/src/main/res/drawable/ic_live_stop.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/microphone_off_icon.xml b/app/src/main/res/drawable/ic_microphone_off.xml similarity index 100% rename from app/src/main/res/drawable/microphone_off_icon.xml rename to app/src/main/res/drawable/ic_microphone_off.xml diff --git a/app/src/main/res/drawable/microphone_icon.xml b/app/src/main/res/drawable/ic_microphone_on.xml similarity index 100% rename from app/src/main/res/drawable/microphone_icon.xml rename to app/src/main/res/drawable/ic_microphone_on.xml diff --git a/app/src/main/res/drawable/pause_icon.xml b/app/src/main/res/drawable/ic_record_pause.xml similarity index 100% rename from app/src/main/res/drawable/pause_icon.xml rename to app/src/main/res/drawable/ic_record_pause.xml diff --git a/app/src/main/res/drawable/record_icon.xml b/app/src/main/res/drawable/ic_record_start.xml similarity index 93% rename from app/src/main/res/drawable/record_icon.xml rename to app/src/main/res/drawable/ic_record_start.xml index 413b6631c..bdeed7ccc 100644 --- a/app/src/main/res/drawable/record_icon.xml +++ b/app/src/main/res/drawable/ic_record_start.xml @@ -1,6 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_resolution_720.xml b/app/src/main/res/drawable/ic_resolution_720.xml new file mode 100644 index 000000000..7eacdac9a --- /dev/null +++ b/app/src/main/res/drawable/ic_resolution_720.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/stream_icon.xml b/app/src/main/res/drawable/stream_icon.xml index fc5d03ebb..9ef1313ce 100644 --- a/app/src/main/res/drawable/stream_icon.xml +++ b/app/src/main/res/drawable/stream_icon.xml @@ -15,8 +15,8 @@ --> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/style_item_setting_switcher_track.xml b/app/src/main/res/drawable/style_item_setting_switcher_track.xml new file mode 100644 index 000000000..a99e23c8d --- /dev/null +++ b/app/src/main/res/drawable/style_item_setting_switcher_track.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/switch_icon.xml b/app/src/main/res/drawable/switch_icon.xml index d9e07872f..0873bd710 100644 --- a/app/src/main/res/drawable/switch_icon.xml +++ b/app/src/main/res/drawable/switch_icon.xml @@ -1,6 +1,6 @@ + android:id="@+id/surfaceView" /> + android:imeOptions="actionGo"/> + app:layout_constraintBottom_toBottomOf="parent"> + app:layout_constraintHorizontal_chainStyle="spread" /> + app:layout_constraintEnd_toStartOf="@id/switch_camera" /> + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_live_setting.xml b/app/src/main/res/layout/item_live_setting.xml new file mode 100644 index 000000000..f083fa25d --- /dev/null +++ b/app/src/main/res/layout/item_live_setting.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/rotation_activity.xml b/app/src/main/res/layout/rotation_activity.xml index 68520ef18..0b070b9aa 100644 --- a/app/src/main/res/layout/rotation_activity.xml +++ b/app/src/main/res/layout/rotation_activity.xml @@ -6,7 +6,6 @@ + android:layout_height="match_parent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/view_live_settings_dialog.xml b/app/src/main/res/layout/view_live_settings_dialog.xml new file mode 100644 index 000000000..aa9e38519 --- /dev/null +++ b/app/src/main/res/layout/view_live_settings_dialog.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/rotation_menu.xml b/app/src/main/res/menu/rotation_menu.xml index 01fb532f3..5f40efdf6 100644 --- a/app/src/main/res/menu/rotation_menu.xml +++ b/app/src/main/res/menu/rotation_menu.xml @@ -1,6 +1,7 @@ - + - + - + - + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_hohem.xml b/app/src/main/res/mipmap-anydpi-v26/ic_hohem.xml new file mode 100644 index 000000000..680e2a0f3 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_hohem.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_hohem_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_hohem_round.xml new file mode 100644 index 000000000..680e2a0f3 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_hohem_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_hohem.webp b/app/src/main/res/mipmap-hdpi/ic_hohem.webp new file mode 100644 index 000000000..d2cf07648 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_hohem.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_hohem_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_hohem_foreground.webp new file mode 100644 index 000000000..2cd7d1eb1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_hohem_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_hohem_round.webp b/app/src/main/res/mipmap-hdpi/ic_hohem_round.webp new file mode 100644 index 000000000..3c8e5912c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_hohem_round.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_scan_qrcode.png b/app/src/main/res/mipmap-hdpi/ic_scan_qrcode.png new file mode 100644 index 000000000..e6e79b293 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_scan_qrcode.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_hohem.webp b/app/src/main/res/mipmap-mdpi/ic_hohem.webp new file mode 100644 index 000000000..94b731cfd Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_hohem.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_hohem_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_hohem_foreground.webp new file mode 100644 index 000000000..65f7bcdcb Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_hohem_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_hohem_round.webp b/app/src/main/res/mipmap-mdpi/ic_hohem_round.webp new file mode 100644 index 000000000..8bb72a660 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_hohem_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_scan_qrcode.png b/app/src/main/res/mipmap-mdpi/ic_scan_qrcode.png new file mode 100644 index 000000000..f3efd514e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_scan_qrcode.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_hohem.webp b/app/src/main/res/mipmap-xhdpi/ic_hohem.webp new file mode 100644 index 000000000..b2f8e71ec Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_hohem.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_hohem_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_hohem_foreground.webp new file mode 100644 index 000000000..15f49255f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_hohem_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_hohem_round.webp b/app/src/main/res/mipmap-xhdpi/ic_hohem_round.webp new file mode 100644 index 000000000..1b19d7ea7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_hohem_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_scan_qrcode.png b/app/src/main/res/mipmap-xhdpi/ic_scan_qrcode.png new file mode 100644 index 000000000..bb86b2d3b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_scan_qrcode.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_hohem.webp b/app/src/main/res/mipmap-xxhdpi/ic_hohem.webp new file mode 100644 index 000000000..0ac12d39f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_hohem.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_hohem_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_hohem_foreground.webp new file mode 100644 index 000000000..92f211d5f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_hohem_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_hohem_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_hohem_round.webp new file mode 100644 index 000000000..b9e4e1499 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_hohem_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_scan_qrcode.png b/app/src/main/res/mipmap-xxhdpi/ic_scan_qrcode.png new file mode 100644 index 000000000..b3e6045d0 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_scan_qrcode.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_hohem.webp b/app/src/main/res/mipmap-xxxhdpi/ic_hohem.webp new file mode 100644 index 000000000..41e84e9af Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_hohem.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_hohem_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_hohem_foreground.webp new file mode 100644 index 000000000..438fb0062 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_hohem_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_hohem_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_hohem_round.webp new file mode 100644 index 000000000..9815c9c57 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_hohem_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_scan_qrcode.png b/app/src/main/res/mipmap-xxxhdpi/ic_scan_qrcode.png new file mode 100644 index 000000000..5ce13c9ee Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_scan_qrcode.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index fb67225e3..1cbb86f3e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,9 @@ - #e74c3c + + #F48442 + #A69D97 #000000 + @color/black #ffffff diff --git a/app/src/main/res/values/ic_hohem_background.xml b/app/src/main/res/values/ic_hohem_background.xml new file mode 100644 index 000000000..12160afe7 --- /dev/null +++ b/app/src/main/res/values/ic_hohem_background.xml @@ -0,0 +1,4 @@ + + + #EE8344 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c729b1861..4ad2218f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,13 +1,18 @@ - RootEncoder streamer + Hohem Live Version: %1$s Old API (16 – 20) From file Screen - Rotation\n(include filters) + + Hohem Live + Camera Live Select your streamer protocol://yourendpoint + rtmp://tx.direct.huya.com/huyalive/1199649870524-1199649870524-0-2399299864504-10057-A-1760610117-1?seq=1760666707697&type=simple + null + rtmps://a.rtmps.youtube.com/live2/xpjt-yrb3-dtft-asvs-1erd Grey scale Negative @@ -71,6 +76,9 @@ CameraX CameraUVC Bitmap - Buffer + Platform + Huya + Tiktok + YouTube diff --git a/encoder/src/main/java/com/pedro/encoder/input/gl/render/ScreenRender.java b/encoder/src/main/java/com/pedro/encoder/input/gl/render/ScreenRender.java index f32516b6f..5fbd4ca0b 100644 --- a/encoder/src/main/java/com/pedro/encoder/input/gl/render/ScreenRender.java +++ b/encoder/src/main/java/com/pedro/encoder/input/gl/render/ScreenRender.java @@ -24,6 +24,7 @@ import androidx.annotation.RequiresApi; import com.pedro.encoder.R; +import com.pedro.encoder.utils.Logger; import com.pedro.encoder.utils.ViewPort; import com.pedro.encoder.utils.gl.AspectRatioMode; import com.pedro.encoder.utils.gl.GlUtil; @@ -41,6 +42,7 @@ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) public class ScreenRender { + public static final String TAG = "ScreenRender"; //rotation matrix private final float[] squareVertexData = { @@ -105,9 +107,10 @@ public void draw(int width, int height, AspectRatioMode mode, int rotation, public void drawEncoder(int width, int height, boolean isPortrait, int rotation, boolean flipStreamVertical, boolean flipStreamHorizontal, ViewPort viewPort) { GlUtil.checkGlError("drawScreen start"); - + Logger.d(TAG, "drawEncoder: width = " + width + "; height = " + height + "; isPortrait = " + isPortrait + "; rotation = " + rotation + "; flipStreamVertical = " + flipStreamVertical + "; flipStreamHorizontal = " + flipStreamHorizontal); updateMatrix(rotation, SizeCalculator.calculateFlip(flipStreamHorizontal, flipStreamVertical), MVPMatrix); - ViewPort viewport = viewPort != null ? viewPort : SizeCalculator.calculateViewPortEncoder(width, height, isPortrait); + ViewPort viewport = SizeCalculator.calculateViewPortEncoder(width, height, isPortrait); + Logger.d(TAG, "drawEncoder: viewport = " + viewport); GLES20.glViewport(viewport.getX(), viewport.getY(), viewport.getWidth(), viewport.getHeight()); draw(); @@ -119,21 +122,24 @@ public void drawPreview( ViewPort viewPort ) { GlUtil.checkGlError("drawScreen start"); - + Logger.d(TAG, "drawPreview: width = " + width + "; height = " + height + "; isPortrait = " + isPortrait + "; mode = " + mode + "; rotation = " + rotation + "; flipStreamVertical = " + flipStreamVertical + "; flipStreamHorizontal = " + flipStreamHorizontal); updateMatrix(rotation, SizeCalculator.calculateFlip(flipStreamHorizontal, flipStreamVertical), MVPMatrix); float factor = (float) streamWidth / (float) streamHeight; int w; int h; + Logger.d(TAG, "drawPreview: streamWidth = " + streamWidth + "; streamHeight = " + streamHeight + "; factor = " + factor + ";"); if (factor >= 1f) { + Logger.d(TAG, "drawPreview: go here"); w = isPortrait ? streamHeight : streamWidth; h = isPortrait ? streamWidth : streamHeight; } else { w = isPortrait ? streamWidth : streamHeight; h = isPortrait ? streamHeight : streamWidth; } - ViewPort viewport = viewPort != null ? viewPort: SizeCalculator.calculateViewPort(mode, width, height, w, h); +// ViewPort viewport = SizeCalculator.calculateViewPort(mode, width, height, w, h); + ViewPort viewport = SizeCalculator.calculateViewPort(mode, width, height, streamWidth, streamHeight); GLES20.glViewport(viewport.getX(), viewport.getY(), viewport.getWidth(), viewport.getHeight()); - + Logger.d(TAG, "drawPreview: x = " + viewport.getX() + "; y = " + viewport.getY() + "; w = " + viewport.getWidth() + "; h = " + viewport.getHeight() + ";"); draw(); } diff --git a/encoder/src/main/java/com/pedro/encoder/input/sources/video/Camera2Source.kt b/encoder/src/main/java/com/pedro/encoder/input/sources/video/Camera2Source.kt index d632c0048..b88dd95df 100644 --- a/encoder/src/main/java/com/pedro/encoder/input/sources/video/Camera2Source.kt +++ b/encoder/src/main/java/com/pedro/encoder/input/sources/video/Camera2Source.kt @@ -33,283 +33,219 @@ import com.pedro.encoder.input.video.CameraCallbacks import com.pedro.encoder.input.video.CameraHelper import com.pedro.encoder.input.video.FrameCapturedCallback import com.pedro.encoder.input.video.facedetector.FaceDetectorCallback +import com.pedro.encoder.utils.Logger /** * Created by pedro on 11/1/24. */ @RequiresApi(Build.VERSION_CODES.LOLLIPOP) -class Camera2Source(context: Context): VideoSource() { +class Camera2Source(context: Context) : VideoSource() { + companion object { + private const val TAG = "Camera2Source" + } - private val camera = Camera2ApiManager(context) - private var facing = CameraHelper.Facing.BACK + private val camera = Camera2ApiManager(context) + private var facing = CameraHelper.Facing.BACK - override fun create(width: Int, height: Int, fps: Int, rotation: Int): Boolean { - val result = checkResolutionSupported(width, height) - if (!result) { - throw IllegalArgumentException("Unsupported resolution: ${width}x$height") + override fun create(width: Int, height: Int, fps: Int, rotation: Int): Boolean { + val result = checkResolutionSupported(width, height) + if (!result) { + throw IllegalArgumentException("Unsupported resolution: ${width}x$height") + } + return true } - return true - } - - override fun start(surfaceTexture: SurfaceTexture) { - this.surfaceTexture = surfaceTexture - if (!isRunning()) { - surfaceTexture.setDefaultBufferSize(width, height) - camera.prepareCamera(surfaceTexture, width, height, fps, facing) - camera.openCameraFacing(facing) + + override fun start(surfaceTexture: SurfaceTexture) { + this.surfaceTexture = surfaceTexture + if (!isRunning()) { + Logger.d(TAG, "start: width = $width, height = $height, fps = $fps, facing = $facing") + surfaceTexture.setDefaultBufferSize(width, height) + camera.prepareCamera(surfaceTexture, width, height, fps, facing) + camera.openCameraFacing(facing) + } } - } - override fun stop() { - if (isRunning()) camera.closeCamera() - } + override fun stop() { + if (isRunning()) camera.closeCamera() + } - override fun release() {} + override fun release() {} + + override fun isRunning(): Boolean = camera.isRunning + + private fun checkResolutionSupported(width: Int, height: Int): Boolean { + if (width % 2 != 0 || height % 2 != 0) { + throw IllegalArgumentException("width and height values must be divisible by 2") + } + val size = Size(width, height) + val resolutions = if (facing == CameraHelper.Facing.BACK) { + camera.cameraResolutionsBack + } else camera.cameraResolutionsFront + Logger.d( + TAG, + "checkResolutionSupported: size = $size, resolutions = ${resolutions.contentToString()}" + ) + Logger.d(TAG, "checkResolutionSupported: camera.levelSupported = ${camera.levelSupported}") + return if (camera.levelSupported == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { + //this is a wrapper of camera1 api. Only listed resolutions are supported + resolutions.contains(size) + } else { + val widthList = resolutions.map { size.width } + val heightList = resolutions.map { size.height } + val maxWidth = widthList.maxOrNull() ?: 0 + val maxHeight = heightList.maxOrNull() ?: 0 + val minWidth = widthList.minOrNull() ?: 0 + val minHeight = heightList.minOrNull() ?: 0 + size.width in minWidth..maxWidth && size.height in minHeight..maxHeight + } + } - override fun isRunning(): Boolean = camera.isRunning + fun switchCamera() { + facing = if (facing == CameraHelper.Facing.BACK) { + CameraHelper.Facing.FRONT + } else { + CameraHelper.Facing.BACK + } + if (isRunning()) { + stop() + surfaceTexture?.let { + start(it) + } + } + } - private fun checkResolutionSupported(width: Int, height: Int): Boolean { - if (width % 2 != 0 || height % 2 != 0) { - throw IllegalArgumentException("width and height values must be divisible by 2") + fun changeResolution() { + if (isRunning()) { + stop() + surfaceTexture?.let { + start(it) + } + } } - val size = Size(width, height) - val resolutions = if (facing == CameraHelper.Facing.BACK) { - camera.cameraResolutionsBack - } else camera.cameraResolutionsFront - return if (camera.levelSupported == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { - //this is a wrapper of camera1 api. Only listed resolutions are supported - resolutions.contains(size) - } else { - val widthList = resolutions.map { size.width } - val heightList = resolutions.map { size.height } - val maxWidth = widthList.maxOrNull() ?: 0 - val maxHeight = heightList.maxOrNull() ?: 0 - val minWidth = widthList.minOrNull() ?: 0 - val minHeight = heightList.minOrNull() ?: 0 - size.width in minWidth..maxWidth && size.height in minHeight..maxHeight + + fun getCameraFacing(): CameraHelper.Facing = facing + + fun getCameraResolutions(facing: CameraHelper.Facing): List { + val resolutions = if (facing == CameraHelper.Facing.FRONT) { + camera.cameraResolutionsFront + } else { + camera.cameraResolutionsBack + } + return resolutions.toList() } - } - fun switchCamera() { - facing = if (facing == CameraHelper.Facing.BACK) { - CameraHelper.Facing.FRONT - } else { - CameraHelper.Facing.BACK + fun setExposure(level: Int) { + if (isRunning()) camera.exposure = level } - if (isRunning()) { - stop() - surfaceTexture?.let { - start(it) - } + + fun getExposure(): Int { + return if (isRunning()) camera.exposure else 0 } - } - fun getCameraFacing() = facing + fun enableLantern() { + if (isRunning()) camera.enableLantern() + } - fun getCameraResolutions(facing: CameraHelper.Facing): List { - val resolutions = if (facing == CameraHelper.Facing.FRONT) { - camera.cameraResolutionsFront - } else { - camera.cameraResolutionsBack + fun disableLantern() { + if (isRunning()) camera.disableLantern() } - return resolutions.toList() - } - fun setExposure(level: Int) { - if (isRunning()) camera.exposure = level - } + fun isLanternEnabled(): Boolean { + return if (isRunning()) camera.isLanternEnabled else false + } - fun getExposure(): Int { - return if (isRunning()) camera.exposure else 0 - } + fun enableAutoFocus(): Boolean { + if (isRunning()) return camera.enableAutoFocus() + return false + } - fun enableLantern() { - if (isRunning()) camera.enableLantern() - } + fun disableAutoFocus(): Boolean { + if (isRunning()) return camera.disableAutoFocus() + return false + } - fun disableLantern() { - if (isRunning()) camera.disableLantern() - } + fun isAutoFocusEnabled(): Boolean { + return if (isRunning()) camera.isAutoFocusEnabled else false + } - fun isLanternEnabled(): Boolean { - return if (isRunning()) camera.isLanternEnabled else false - } + fun tapToFocus(event: MotionEvent): Boolean { + return camera.tapToFocus(event) + } - fun enableAutoFocus(): Boolean { - if (isRunning()) return camera.enableAutoFocus() - return false - } + @JvmOverloads + fun setZoom(event: MotionEvent, delta: Float = 0.1f) { + if (isRunning()) camera.setZoom(event, delta) + } - fun disableAutoFocus(): Boolean { - if (isRunning()) return camera.disableAutoFocus() - return false - } + fun setZoom(level: Float) { + if (isRunning()) camera.zoom = level + } - fun isAutoFocusEnabled(): Boolean { - return if (isRunning()) camera.isAutoFocusEnabled else false - } + fun getZoomRange(): Range = camera.zoomRange - fun tapToFocus(view: View, event: MotionEvent): Boolean { - return camera.tapToFocus(view, event) - } + fun getZoom(): Float = camera.zoom - @JvmOverloads - fun setZoom(event: MotionEvent, delta: Float = 0.1f) { - if (isRunning()) camera.setZoom(event, delta) - } + fun enableFaceDetection(callback: FaceDetectorCallback): Boolean { + return if (isRunning()) camera.enableFaceDetection(callback) else false + } + + fun disableFaceDetection() { + if (isRunning()) camera.disableFaceDetection() + } - fun setZoom(level: Float) { - if (isRunning()) camera.zoom = level - } + fun isFaceDetectionEnabled() = camera.isFaceDetectionEnabled() - fun getZoomRange(): Range = camera.zoomRange + fun camerasAvailable(): Array = camera.camerasAvailable - fun getZoom(): Float = camera.zoom + fun getCurrentCameraId() = camera.getCurrentCameraId() - fun enableFaceDetection(callback: FaceDetectorCallback): Boolean { - return if (isRunning()) camera.enableFaceDetection(callback) else false - } + fun openCameraId(id: String) { + if (isRunning()) camera.reOpenCamera(id) + } - fun enableFrameCaptureCallback(frameCapturedCallback: FrameCapturedCallback?) { - camera.enableFrameCaptureCallback(frameCapturedCallback) - } + fun enableOpticalVideoStabilization(): Boolean { + return if (isRunning()) camera.enableOpticalVideoStabilization() else false + } - fun disableFaceDetection() { - if (isRunning()) camera.disableFaceDetection() - } + fun disableOpticalVideoStabilization() { + if (isRunning()) camera.disableOpticalVideoStabilization() + } - fun isFaceDetectionEnabled() = camera.isFaceDetectionEnabled() + fun isOpticalVideoStabilizationEnabled() = camera.isOpticalStabilizationEnabled - fun camerasAvailable(): Array = camera.camerasAvailable + fun enableVideoStabilization(): Boolean { + return if (isRunning()) camera.enableVideoStabilization() else false + } - fun getCurrentCameraId() = camera.getCurrentCameraId() + fun disableVideoStabilization() { + if (isRunning()) camera.disableVideoStabilization() + } - fun openCameraId(id: String) { - if (isRunning()) camera.reOpenCamera(id) - } + fun isVideoStabilizationEnabled() = camera.isVideoStabilizationEnabled - fun enableOpticalVideoStabilization(): Boolean { - return if (isRunning()) camera.enableOpticalVideoStabilization() else false - } - - fun disableOpticalVideoStabilization() { - if (isRunning()) camera.disableOpticalVideoStabilization() - } - - fun isOpticalVideoStabilizationEnabled() = camera.isOpticalStabilizationEnabled - - fun enableVideoStabilization(): Boolean { - return if (isRunning()) camera.enableVideoStabilization() else false - } - - fun disableVideoStabilization() { - if (isRunning()) camera.disableVideoStabilization() - } - - fun isVideoStabilizationEnabled() = camera.isVideoStabilizationEnabled - - fun enableAutoExposure(): Boolean { - return if (isRunning()) camera.enableAutoExposure() else false - } - - fun disableAutoExposure() { - if (isRunning()) camera.disableAutoExposure() - } - - fun isAutoExposureEnabled() = camera.isAutoExposureEnabled - - @JvmOverloads - fun addImageListener( - format: Int, - maxImages: Int, - autoClose: Boolean = true, - listener: ImageCallback - ) { - val w = if (rotation == 90 || rotation == 270) height else width - val h = if (rotation == 90 || rotation == 270) width else height - camera.addImageListener(w, h, format, maxImages, autoClose, listener) - } - - fun removeImageListener() { - camera.removeImageListener() - } - - @RequiresApi(Build.VERSION_CODES.P) - fun physicalCamerasAvailable() = camera.getPhysicalCamerasAvailable() - - @RequiresApi(Build.VERSION_CODES.P) - fun openPhysicalCamera(id: String?) { - camera.openPhysicalCamera(id) - } - - fun setCameraCallback(callbacks: CameraCallbacks?) { - camera.setCameraCallbacks(callbacks) - } - - /** - * @param mode value from CameraCharacteristics.AWB_MODE_* - */ - fun enableAutoWhiteBalance(mode: Int) = camera.enableAutoWhiteBalance(mode) - - fun disableAutoWhiteBalance() { - camera.disableAutoWhiteBalance() - } - - fun isAutoWhiteBalanceEnabled() = camera.isAutoWhiteBalanceEnabled - - fun getWhiteBalance() = camera.getWhiteBalance() - - fun getAutoWhiteBalanceModesAvailable() = camera.getAutoWhiteBalanceModesAvailable() - - fun setColorCorrectionGains(red: Float, greenEven: Float, greenOdd: Float, blue: Float) = - camera.setColorCorrectionGains(red, greenEven, greenOdd, blue) - - @JvmOverloads - fun getMaxSupportedFps(size: Size?, facing: CameraHelper.Facing = getCameraFacing()): Int { - return camera.getSupportedFps(size, facing).maxOfOrNull { it.upper } ?: 30 - } - - /** - * Set the required resolution for the camera. - * Must be called before prepareVideo or changeVideoSource. Otherwise it will be ignored. - */ - fun setRequiredResolution(size: Size?) { - size?.let { checkResolutionSupported(it.width, it.height) } - camera.setRequiredResolution(size) - } - - /** - * Add a callback to detect the camera availability. - * Set null value to remove the callback - */ - fun setAvailabilityCallback(callback: CameraManager.AvailabilityCallback?) { - camera.setAvailabilityCallback(callback) - } - - /** - * Re start camera if possible, return true or false depend if can do it or not. - */ - fun restart(): Boolean { - if (isRunning()) camera.reOpenCamera(camera.getCurrentCameraId()) - else if (camera.isPrepared) camera.openCameraId(camera.getCurrentCameraId()) - else return false - return true - } - - /** - * @return true of false depend if success or fail to do it. - * - * Set custom values to the camera. - * Need to be called after start or it will return false. - * Build and apply are done by the library automatically. - * - * For example, if you want disable autoExposure: - * - * camera.setCustomRequest { builder -> - * builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF) - * } - */ - fun setCustomRequest(request: (CaptureRequest.Builder) -> Unit): Boolean { - return camera.setCustomRequest(request) - } + fun enableAutoExposure(): Boolean { + return if (isRunning()) camera.enableAutoExposure() else false + } + + fun disableAutoExposure() { + if (isRunning()) camera.disableAutoExposure() + } + + fun isAutoExposureEnabled() = camera.isAutoExposureEnabled + + @JvmOverloads + fun addImageListener( + format: Int, + maxImages: Int, + autoClose: Boolean = true, + listener: ImageCallback + ) { + val w = if (rotation == 90 || rotation == 270) height else width + val h = if (rotation == 90 || rotation == 270) width else height + camera.addImageListener(w, h, format, maxImages, autoClose, listener) + } + + fun removeImageListener() { + camera.removeImageListener() + } } \ No newline at end of file diff --git a/encoder/src/main/java/com/pedro/encoder/utils/Logger.kt b/encoder/src/main/java/com/pedro/encoder/utils/Logger.kt new file mode 100644 index 000000000..2fb9a75ce --- /dev/null +++ b/encoder/src/main/java/com/pedro/encoder/utils/Logger.kt @@ -0,0 +1,72 @@ +package com.pedro.encoder.utils + +import android.util.Log + + +object Logger { + private const val TAG = "HUANG" + private const val ERROR = "ANDROID_ERROR:" + private const val WARN = "ANDROID_WARN:" + private const val RUNTIME_EXCEPTION = "ANDROID_RUNTIME_EXCEPTION" +// private val DEBUG = BuildConfig.DEBUG + private val DEBUG = true + + @JvmStatic + fun v(subTag: String, message: String){ + if (DEBUG) { + Log.v(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun d(subTag: String, message: String){ + if (DEBUG) { + Log.d(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun i(subTag: String, message: String){ + if (DEBUG) { + Log.i(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun w(subTag: String, message: String){ + if (DEBUG) { + Log.w(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $WARN $message") + } + } + @JvmStatic + fun e(subTag: String, message: String){ + if (DEBUG) { + Log.e(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $ERROR $message") + } + } + + @JvmStatic + fun throwRuntimeException(){ + RuntimeException(TAG).printStackTrace() + } + + @JvmStatic + fun printStackTrace(tag: String){ + //打印堆栈而不退出 + d(tag, Log.getStackTraceString(Throwable())) + + Exception("debug log").printStackTrace() + + for (i in Thread.currentThread().stackTrace){ + i(tag, i.toString()) + } + val runtimeException = RuntimeException() + runtimeException.fillInStackTrace() + + try { + i(tag, "----------------------throw NullPointerException----------------------") + throw NullPointerException() + } catch (nullPointer: NullPointerException) { + i(tag, "----------------------catch NullPointerException----------------------") + e(tag, Log.getStackTraceString(nullPointer)) + } + i(tag, "----------------------end NullPointerException ----------------------") + } +} \ No newline at end of file diff --git a/encoder/src/main/java/com/pedro/encoder/utils/gl/SizeCalculator.java b/encoder/src/main/java/com/pedro/encoder/utils/gl/SizeCalculator.java index 2a13bf218..e5510bb03 100644 --- a/encoder/src/main/java/com/pedro/encoder/utils/gl/SizeCalculator.java +++ b/encoder/src/main/java/com/pedro/encoder/utils/gl/SizeCalculator.java @@ -16,6 +16,7 @@ package com.pedro.encoder.utils.gl; +import com.pedro.encoder.utils.Logger; import com.pedro.encoder.utils.ViewPort; import kotlin.Pair; @@ -26,11 +27,14 @@ public class SizeCalculator { + public static final String TAG = "SizeCalculator"; + public static ViewPort calculateViewPort(AspectRatioMode mode, int previewWidth, int previewHeight, int streamWidth, int streamHeight) { if (mode == AspectRatioMode.NONE) { return new ViewPort(0, 0, previewWidth, previewHeight); } + Logger.d(TAG, "calculateViewPort: mode = " + mode + "; previewWidth = " + previewWidth + "; previewHeight = " + previewHeight + "; streamWidth = " + streamWidth + "; streamHeight = " + streamHeight + ";"); float streamAspectRatio = (float) streamWidth / (float) streamHeight; float previewAspectRatio = (float) previewWidth / (float) previewHeight; int xo = 0; @@ -38,13 +42,39 @@ public static ViewPort calculateViewPort(AspectRatioMode mode, int previewWidth, int xf = previewWidth; int yf = previewHeight; if (mode == AspectRatioMode.Adjust) { - if (streamAspectRatio > previewAspectRatio) { - yf = streamHeight * previewWidth / streamWidth; - yo = (yf - previewHeight) / -2; - } else { - xf = streamWidth * previewHeight / streamHeight; - xo = (xf - previewWidth) / -2; + Logger.d(TAG, "calculateViewPort: streamAspectRatio = " + streamAspectRatio + "; previewAspectRatio = " + previewAspectRatio + ";"); + //640x480是宽x高,在屏幕显示的时候640是竖直方向,480是水平方向 + float wr = (float) streamHeight / previewWidth;//水平方向的占比, 一般后者比前者大,比值是小于1的 + float hr = (float) streamWidth / previewHeight;//竖直方向的占比 + if(wr <= 1.0 && hr <= 1.0){ + Logger.d(TAG, "calculateViewPort: 1 wr = " + wr + "; hr = " + hr); + if(wr > hr) {//以水平方向去适配 + xo = 0; + yo = (int) ((previewHeight - streamWidth / wr) / 2); + xf = previewWidth; + yf = (int) (streamWidth / wr); + } + }else{ + Logger.d(TAG, "calculateViewPort: 2 wr = " + wr + "; hr = " + hr); + wr = (float) previewWidth / streamWidth;//水平方向的占比 + hr = (float) previewHeight / streamHeight;//竖直方向的占比 + Logger.d(TAG, "calculateViewPort: 3 wr = " + wr + "; hr = " + hr); + if(wr < hr) {//将画面倒转过来,以水平方向去适配 + xo = 0; + yo = (int) ((previewHeight - streamHeight * wr) / 2) + 15; + xf = previewWidth; + yf = (int) (streamHeight * wr); + } } + + +// if (streamAspectRatio > previewAspectRatio) { +// yf = streamHeight * previewWidth / streamWidth; +// yo = (yf - previewHeight) / -2; +// } else { +// xf = streamWidth * previewHeight / streamHeight; +// xo = (xf - previewWidth) / -2; +// } } else { //AspectRatioMode.Fill if (streamAspectRatio > previewAspectRatio) { xf = streamWidth * previewHeight / streamHeight; @@ -54,11 +84,13 @@ public static ViewPort calculateViewPort(AspectRatioMode mode, int previewWidth, yo = (yf - previewHeight) / -2; } } + Logger.d(TAG, "calculateViewPort: xo = " + xo + "; yo = " + yo + "; xf = " + xf + "; yf = " + yf + ";"); return new ViewPort(xo, yo, xf, yf); } public static ViewPort calculateViewPortEncoder(int streamWidth, int streamHeight, boolean isPortrait) { float factor = (float) streamWidth / (float) streamHeight; + Logger.i(TAG, "calculateViewPortEncoder: factor = " + factor + "; isPortrait = " + isPortrait); if (factor >= 1f) { if (isPortrait) { int width = (int) (streamHeight / factor); diff --git a/encoder/src/main/java/com/pedro/encoder/video/VideoEncoder.java b/encoder/src/main/java/com/pedro/encoder/video/VideoEncoder.java index 2552be7b1..8d3cf9b77 100644 --- a/encoder/src/main/java/com/pedro/encoder/video/VideoEncoder.java +++ b/encoder/src/main/java/com/pedro/encoder/video/VideoEncoder.java @@ -39,6 +39,7 @@ import com.pedro.encoder.input.video.FpsLimiter; import com.pedro.encoder.input.video.GetCameraData; import com.pedro.encoder.utils.CodecUtil; +import com.pedro.encoder.utils.Logger; import com.pedro.encoder.utils.yuv.YUVUtil; import java.nio.ByteBuffer; @@ -61,10 +62,13 @@ public class VideoEncoder extends BaseEncoder implements GetCameraData { //surface to buffer encoder private Surface inputSurface; - private int width = 640; - private int height = 480; +// private int width = 640; +// private int height = 480; + private int width = 1440; + private int height = 1080; private int fps = 30; - private int bitRate = 1200 * 1024; //in kbps +// private int bitRate = 1200 * 1024; //in kbps + private int bitRate = 2500 * 1024; //in kbps private int rotation = 90; private int iFrameInterval = 2; private long firstTimestamp = 0; @@ -141,7 +145,8 @@ public boolean prepareVideoEncoder(int width, int height, int fps, int bitRate, resolution = width + "x" + height; videoFormat = MediaFormat.createVideoFormat(type, width, height); } - Log.i(TAG, "Prepare video info: " + this.formatVideoEncoder.name() + ", " + resolution); + Logger.i(TAG, "prepareVideoEncoder: rotation = " + rotation + "; videoFormat = " + videoFormat); + Logger.i(TAG, "Prepare video info: " + this.formatVideoEncoder.name() + ", " + resolution); videoFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, this.formatVideoEncoder.getFormatCodec()); videoFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 0); @@ -284,10 +289,18 @@ public int getWidth() { return width; } + public void setWidth(int width) { + this.width = width; + } + public int getHeight() { return height; } + public void setHeight(int height) { + this.height = height; + } + public int getRotation() { return rotation; } @@ -308,6 +321,10 @@ public int getBitRate() { return bitRate; } + public void setBitRate(int bitRate) { + this.bitRate = bitRate; + } + public void setForceFps(int fps) { fpsLimiter.setFPS(fps); } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c49b5f00..1a354b7a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,9 +15,13 @@ multidex = "2.0.1" annotation = "1.9.1" coroutines = "1.10.2" junit = "4.13.2" -mockito = "6.1.0" -uvcandroid = "1.0.11" -media3 = "1.8.0" +mockito = "5.4.0" +ktor = "2.3.13" +uvcandroid = "1.0.7" +media3 = "1.5.0" +firebaseCrashlyticsBuildtools = "3.0.2" +eventbus = "3.3.1" +recyclerview = "1.4.0" [libraries] androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } @@ -35,6 +39,9 @@ ktor-network-tls = { module = "io.ktor:ktor-network-tls", version.ref = "ktor" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito" } uvcandroid = { module = "com.herohan:UVCAndroid", version.ref = "uvcandroid" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } +eventbus = {module = "org.greenrobot:eventbus", version.ref = "eventbus"} +recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 279597f11..b2121664b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jun 26 11:05:20 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/library/src/main/java/com/pedro/library/base/StreamBase.kt b/library/src/main/java/com/pedro/library/base/StreamBase.kt index 8491f7e37..d1aaba6ca 100644 --- a/library/src/main/java/com/pedro/library/base/StreamBase.kt +++ b/library/src/main/java/com/pedro/library/base/StreamBase.kt @@ -41,6 +41,7 @@ import com.pedro.encoder.input.sources.audio.NoAudioSource import com.pedro.encoder.input.sources.video.NoVideoSource import com.pedro.encoder.input.sources.video.VideoSource import com.pedro.encoder.utils.CodecUtil +import com.pedro.encoder.utils.Logger import com.pedro.encoder.video.FormatVideoEncoder import com.pedro.encoder.video.GetVideoData import com.pedro.encoder.video.VideoEncoder @@ -70,6 +71,9 @@ abstract class StreamBase( vSource: VideoSource, aSource: AudioSource ) { + companion object { + private const val TAG = "StreamBase" + } private val getMicrophoneData = object: GetMicrophoneData { override fun inputPCMData(frame: Frame) { @@ -130,6 +134,7 @@ abstract class StreamBase( } differentRecordResolution = true } + Logger.d(TAG, "prepareVideo: differentRecordResolution: $differentRecordResolution, width = $width, height = $height, bitrate = $bitrate, fps = $fps, iFrameInterval = $iFrameInterval, recordWidth = $recordWidth, recordHeight = $recordHeight, recordBitrate = $recordBitrate") val videoResult = videoSource.init(max(width, recordWidth), max(height, recordHeight), fps, rotation) if (videoResult) { if (differentRecordResolution) { @@ -140,6 +145,7 @@ abstract class StreamBase( if (rotation == 90 || rotation == 270) glInterface.setEncoderSize(height, width) else glInterface.setEncoderSize(width, height) val isPortrait = rotation == 90 || rotation == 270 + Logger.d(TAG, "prepareVideo: isPortrait = $isPortrait, rotation = $rotation, width = $width, height = $height, recordWidth = $recordWidth, recordHeight = $recordHeight") glInterface.setIsPortrait(isPortrait) glInterface.setCameraOrientation(if (rotation == 0) 270 else rotation - 90) glInterface.setOrientationConfig(videoSource.getOrientationConfig()) @@ -499,6 +505,16 @@ abstract class StreamBase( protected fun getVideoResolution() = Size(videoEncoder.width, videoEncoder.height) + fun setVideoResolution(width: Int, height: Int) { + videoEncoder.width = width + videoEncoder.height = height + glInterface.setEncoderSize(width, height) + } + + fun setVideoBitRate(bitRate: Int) { + videoEncoder.bitRate = bitRate + } + protected fun getVideoFps() = videoEncoder.fps private fun startSources() { diff --git a/library/src/main/java/com/pedro/library/generic/GenericFromFile.kt b/library/src/main/java/com/pedro/library/generic/GenericFromFile.kt index 5a16bbabf..f94052cf5 100644 --- a/library/src/main/java/com/pedro/library/generic/GenericFromFile.kt +++ b/library/src/main/java/com/pedro/library/generic/GenericFromFile.kt @@ -25,6 +25,7 @@ import com.pedro.common.VideoCodec import com.pedro.common.onMainThreadHandler import com.pedro.encoder.input.decoder.AudioDecoderInterface import com.pedro.encoder.input.decoder.VideoDecoderInterface +import com.pedro.encoder.utils.Logger import com.pedro.library.base.FromFileBase import com.pedro.library.util.streamclient.GenericStreamClient import com.pedro.library.util.streamclient.RtmpStreamClient @@ -41,6 +42,9 @@ import java.nio.ByteBuffer @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) class GenericFromFile: FromFileBase { + companion object { + private const val TAG = "GenericFromFile" + } private val streamClientListener = object: StreamClientListener { override fun onRequestKeyframe() { @@ -123,6 +127,7 @@ class GenericFromFile: FromFileBase { streamClient.connecting(url) if (url.startsWith("rtmp", ignoreCase = true)) { connectedType = ClientType.RTMP + Logger.d(TAG, "startStreamImp: videoEncoder.rotation = ${videoEncoder.rotation}, videoEncoder.width = ${videoEncoder.width}, videoEncoder.height = ${videoEncoder.height}") if (videoEncoder.rotation == 90 || videoEncoder.rotation == 270) { rtmpClient.setVideoResolution(videoEncoder.height, videoEncoder.width) } else { diff --git a/library/src/main/java/com/pedro/library/generic/GenericStream.kt b/library/src/main/java/com/pedro/library/generic/GenericStream.kt index 2991fb520..9d64a0807 100644 --- a/library/src/main/java/com/pedro/library/generic/GenericStream.kt +++ b/library/src/main/java/com/pedro/library/generic/GenericStream.kt @@ -28,7 +28,7 @@ import com.pedro.encoder.input.sources.audio.AudioSource import com.pedro.encoder.input.sources.audio.MicrophoneSource import com.pedro.encoder.input.sources.video.Camera2Source import com.pedro.encoder.input.sources.video.VideoSource -import com.pedro.library.base.StreamBase +import com.pedro.encoder.utils.Logger import com.pedro.library.util.streamclient.GenericStreamClient import com.pedro.library.util.streamclient.RtmpStreamClient import com.pedro.library.util.streamclient.RtspStreamClient @@ -53,116 +53,122 @@ class GenericStream( private val connectChecker: ConnectChecker, videoSource: VideoSource, audioSource: AudioSource -): StreamBase(context, videoSource, audioSource) { +) : StreamBase(context, videoSource, audioSource) { + companion object { + private const val TAG = "GenericStream" + } - private val streamClientListener = object: StreamClientListener { - override fun onRequestKeyframe() { - requestKeyframe() + private val streamClientListener = object : StreamClientListener { + override fun onRequestKeyframe() { + requestKeyframe() + } } - } - private val rtmpClient = RtmpClient(connectChecker) - private val rtspClient = RtspClient(connectChecker) - private val srtClient = SrtClient(connectChecker) - private val udpClient = UdpClient(connectChecker) - private val streamClient = GenericStreamClient( - RtmpStreamClient(rtmpClient, streamClientListener), - RtspStreamClient(rtspClient, streamClientListener), - SrtStreamClient(srtClient, streamClientListener), - UdpStreamClient(udpClient, streamClientListener) - ) - private var connectedType = ClientType.NONE + private val rtmpClient = RtmpClient(connectChecker) + private val rtspClient = RtspClient(connectChecker) + private val srtClient = SrtClient(connectChecker) + private val udpClient = UdpClient(connectChecker) + private val streamClient = GenericStreamClient( + RtmpStreamClient(rtmpClient, streamClientListener), + RtspStreamClient(rtspClient, streamClientListener), + SrtStreamClient(srtClient, streamClientListener), + UdpStreamClient(udpClient, streamClientListener) + ) + private var connectedType = ClientType.NONE - constructor(context: Context, connectChecker: ConnectChecker): - this(context, connectChecker, Camera2Source(context), MicrophoneSource()) + constructor(context: Context, connectChecker: ConnectChecker) : + this(context, connectChecker, Camera2Source(context), MicrophoneSource()) - override fun getStreamClient(): GenericStreamClient = streamClient + override fun getStreamClient(): GenericStreamClient = streamClient - override fun setVideoCodecImp(codec: VideoCodec) { - if (codec != VideoCodec.H264 && codec != VideoCodec.H265) { - throw IllegalArgumentException("Unsupported codec: ${codec.name}. Generic only support video ${VideoCodec.H264.name} and ${VideoCodec.H265.name}") + override fun setVideoCodecImp(codec: VideoCodec) { + if (codec != VideoCodec.H264 && codec != VideoCodec.H265) { + throw IllegalArgumentException("Unsupported codec: ${codec.name}. Generic only support video ${VideoCodec.H264.name} and ${VideoCodec.H265.name}") + } + rtmpClient.setVideoCodec(codec) + rtspClient.setVideoCodec(codec) + srtClient.setVideoCodec(codec) + udpClient.setVideoCodec(codec) } - rtmpClient.setVideoCodec(codec) - rtspClient.setVideoCodec(codec) - srtClient.setVideoCodec(codec) - udpClient.setVideoCodec(codec) - } - override fun setAudioCodecImp(codec: AudioCodec) { - if (codec != AudioCodec.AAC) { - throw IllegalArgumentException("Unsupported codec: ${codec.name}. Generic only support audio ${AudioCodec.AAC.name}") + override fun setAudioCodecImp(codec: AudioCodec) { + if (codec != AudioCodec.AAC) { + throw IllegalArgumentException("Unsupported codec: ${codec.name}. Generic only support audio ${AudioCodec.AAC.name}") + } + rtmpClient.setAudioCodec(codec) + rtspClient.setAudioCodec(codec) + srtClient.setAudioCodec(codec) + udpClient.setAudioCodec(codec) } - rtmpClient.setAudioCodec(codec) - rtspClient.setAudioCodec(codec) - srtClient.setAudioCodec(codec) - udpClient.setAudioCodec(codec) - } - override fun onAudioInfoImp(sampleRate: Int, isStereo: Boolean) { - rtmpClient.setAudioInfo(sampleRate, isStereo) - rtspClient.setAudioInfo(sampleRate, isStereo) - srtClient.setAudioInfo(sampleRate, isStereo) - udpClient.setAudioInfo(sampleRate, isStereo) - } + override fun onAudioInfoImp(sampleRate: Int, isStereo: Boolean) { + rtmpClient.setAudioInfo(sampleRate, isStereo) + rtspClient.setAudioInfo(sampleRate, isStereo) + srtClient.setAudioInfo(sampleRate, isStereo) + udpClient.setAudioInfo(sampleRate, isStereo) + } - override fun startStreamImp(endPoint: String) { - streamClient.connecting(endPoint) - if (endPoint.startsWith("rtmp", ignoreCase = true)) { - connectedType = ClientType.RTMP - val resolution = super.getVideoResolution() - rtmpClient.setVideoResolution(resolution.width, resolution.height) - rtmpClient.setFps(super.getVideoFps()) - rtmpClient.connect(endPoint) - } else if (endPoint.startsWith("rtsp", ignoreCase = true)) { - connectedType = ClientType.RTSP - rtspClient.connect(endPoint) - } else if (endPoint.startsWith("srt", ignoreCase = true)) { - connectedType = ClientType.SRT - srtClient.connect(endPoint) - } else if (endPoint.startsWith("udp", ignoreCase = true)) { - connectedType = ClientType.UDP - udpClient.connect(endPoint) - } else { - onMainThreadHandler { - connectChecker.onConnectionFailed("Unsupported protocol. Only support rtmp, rtsp and srt") - } + override fun startStreamImp(endPoint: String) { + streamClient.connecting(endPoint) + if (endPoint.startsWith("rtmp", ignoreCase = true)) { + connectedType = ClientType.RTMP + val resolution = super.getVideoResolution() + val fps = super.getVideoFps() + Logger.d(TAG, "startStreamImp: resolution = $resolution, fps = $fps") + rtmpClient.setVideoResolution(resolution.width, resolution.height) +// rtmpClient.setVideoResolution(1080, 1920) + rtmpClient.setFps(fps) + rtmpClient.connect(endPoint) + } else if (endPoint.startsWith("rtsp", ignoreCase = true)) { + connectedType = ClientType.RTSP + rtspClient.connect(endPoint) + } else if (endPoint.startsWith("srt", ignoreCase = true)) { + connectedType = ClientType.SRT + srtClient.connect(endPoint) + } else if (endPoint.startsWith("udp", ignoreCase = true)) { + connectedType = ClientType.UDP + udpClient.connect(endPoint) + } else { + onMainThreadHandler { + connectChecker.onConnectionFailed("Unsupported protocol. Only support rtmp, rtsp and srt") + } + } } - } - override fun stopStreamImp() { - when (connectedType) { - ClientType.RTMP -> rtmpClient.disconnect() - ClientType.RTSP -> rtspClient.disconnect() - ClientType.SRT -> srtClient.disconnect() - ClientType.UDP -> udpClient.disconnect() - else -> {} + override fun stopStreamImp() { + when (connectedType) { + ClientType.RTMP -> rtmpClient.disconnect() + ClientType.RTSP -> rtspClient.disconnect() + ClientType.SRT -> srtClient.disconnect() + ClientType.UDP -> udpClient.disconnect() + else -> {} + } + connectedType = ClientType.NONE } - connectedType = ClientType.NONE - } - override fun getAudioDataImp(audioBuffer: ByteBuffer, info: MediaCodec.BufferInfo) { - when (connectedType) { - ClientType.RTMP -> rtmpClient.sendAudio(audioBuffer, info) - ClientType.RTSP -> rtspClient.sendAudio(audioBuffer, info) - ClientType.SRT -> srtClient.sendAudio(audioBuffer, info) - ClientType.UDP -> udpClient.sendAudio(audioBuffer, info) - else -> {} + override fun getAudioDataImp(audioBuffer: ByteBuffer, info: MediaCodec.BufferInfo) { + when (connectedType) { + ClientType.RTMP -> rtmpClient.sendAudio(audioBuffer, info) + ClientType.RTSP -> rtspClient.sendAudio(audioBuffer, info) + ClientType.SRT -> srtClient.sendAudio(audioBuffer, info) + ClientType.UDP -> udpClient.sendAudio(audioBuffer, info) + else -> {} + } } - } - override fun onVideoInfoImp(sps: ByteBuffer, pps: ByteBuffer?, vps: ByteBuffer?) { - rtmpClient.setVideoInfo(sps, pps, vps) - rtspClient.setVideoInfo(sps, pps, vps) - srtClient.setVideoInfo(sps, pps, vps) - udpClient.setVideoInfo(sps, pps, vps) - } + override fun onVideoInfoImp(sps: ByteBuffer, pps: ByteBuffer?, vps: ByteBuffer?) { + rtmpClient.setVideoInfo(sps, pps, vps) + rtspClient.setVideoInfo(sps, pps, vps) + srtClient.setVideoInfo(sps, pps, vps) + udpClient.setVideoInfo(sps, pps, vps) + } - override fun getVideoDataImp(videoBuffer: ByteBuffer, info: MediaCodec.BufferInfo) { - when (connectedType) { - ClientType.RTMP -> rtmpClient.sendVideo(videoBuffer, info) - ClientType.RTSP -> rtspClient.sendVideo(videoBuffer, info) - ClientType.SRT -> srtClient.sendVideo(videoBuffer, info) - ClientType.UDP -> udpClient.sendVideo(videoBuffer, info) - else -> {} + override fun getVideoDataImp(videoBuffer: ByteBuffer, info: MediaCodec.BufferInfo) { + when (connectedType) { + ClientType.RTMP -> rtmpClient.sendVideo(videoBuffer, info) + ClientType.RTSP -> rtspClient.sendVideo(videoBuffer, info) + ClientType.SRT -> srtClient.sendVideo(videoBuffer, info) + ClientType.UDP -> udpClient.sendVideo(videoBuffer, info) + else -> {} + } } - } } \ No newline at end of file diff --git a/library/src/main/java/com/pedro/library/rtmp/RtmpStream.kt b/library/src/main/java/com/pedro/library/rtmp/RtmpStream.kt index 67eddec43..576c667fe 100644 --- a/library/src/main/java/com/pedro/library/rtmp/RtmpStream.kt +++ b/library/src/main/java/com/pedro/library/rtmp/RtmpStream.kt @@ -27,7 +27,7 @@ import com.pedro.encoder.input.sources.audio.AudioSource import com.pedro.encoder.input.sources.audio.MicrophoneSource import com.pedro.encoder.input.sources.video.Camera2Source import com.pedro.encoder.input.sources.video.VideoSource -import com.pedro.library.base.StreamBase +import com.pedro.encoder.utils.Logger import com.pedro.library.util.streamclient.RtmpStreamClient import com.pedro.library.util.streamclient.StreamClientListener import com.pedro.rtmp.rtmp.RtmpClient @@ -45,6 +45,9 @@ class RtmpStream( context: Context, connectChecker: ConnectChecker, videoSource: VideoSource, audioSource: AudioSource ): StreamBase(context, videoSource, audioSource) { + companion object { + private const val TAG = "RtmpStream" + } private val rtmpClient = RtmpClient(connectChecker) private val streamClientListener = object: StreamClientListener { @@ -70,6 +73,8 @@ class RtmpStream( } override fun startStreamImp(endPoint: String) { + Logger.d(TAG, "startStreamImp: endPoint = $endPoint") + Logger.throwRuntimeException() val resolution = super.getVideoResolution() rtmpClient.setVideoResolution(resolution.width, resolution.height) rtmpClient.setFps(super.getVideoFps()) diff --git a/library/src/main/java/com/pedro/library/view/GlStreamInterface.kt b/library/src/main/java/com/pedro/library/view/GlStreamInterface.kt index 0f4aa9323..1a47fd564 100644 --- a/library/src/main/java/com/pedro/library/view/GlStreamInterface.kt +++ b/library/src/main/java/com/pedro/library/view/GlStreamInterface.kt @@ -34,7 +34,7 @@ import com.pedro.encoder.input.sources.OrientationConfig import com.pedro.encoder.input.sources.OrientationForced import com.pedro.encoder.input.video.CameraHelper import com.pedro.encoder.input.video.FpsLimiter -import com.pedro.encoder.utils.ViewPort +import com.pedro.encoder.utils.Logger import com.pedro.encoder.utils.gl.AspectRatioMode import com.pedro.encoder.utils.gl.GlUtil import com.pedro.library.util.Filter @@ -54,6 +54,9 @@ import kotlin.math.max */ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) class GlStreamInterface(private val context: Context): OnFrameAvailableListener, GlInterface { + companion object { + private const val TAG = "GlStreamInterface" + } private var takePhotoCallback: TakePhotoCallback? = null private val running = AtomicBoolean(false) @@ -221,6 +224,7 @@ class GlStreamInterface(private val context: Context): OnFrameAvailableListener, } private fun draw(forced: Boolean) { + Logger.d(TAG, "draw: forced = $forced") if (!isRunning) return val limitFps = fpsLimiter.limitFPS() if (!forced) forceRender.frameAvailable() @@ -237,28 +241,18 @@ class GlStreamInterface(private val context: Context): OnFrameAvailableListener, } } - if (surfaceManager.isReady && mainRender.isReady()) { - if (!surfaceManager.makeCurrent()) return - mainRender.updateFrame() - mainRender.drawSource() - surfaceManager.swapBuffer() - } - - val orientation = when (orientationForced) { - OrientationForced.PORTRAIT -> true - OrientationForced.LANDSCAPE -> false - OrientationForced.NONE -> isPortrait - } - val orientationPreview = when (orientationForced) { - OrientationForced.PORTRAIT -> true - OrientationForced.LANDSCAPE -> false - OrientationForced.NONE -> isPortraitPreview - } - if (surfaceManagerEncoder.isReady || surfaceManagerEncoderRecord.isReady || surfaceManagerPhoto.isReady) { - mainRender.drawFilters(false) - } +// val orientation = when (orientationForced) { +// OrientationForced.PORTRAIT -> true +// OrientationForced.LANDSCAPE -> false +// OrientationForced.NONE -> isPortrait +// } + //todo 临时修改:设置给记录仪的是横屏模式; + val orientation = false +// val orientation = true + Logger.d(TAG, "draw: orientation = $orientation") // render VideoEncoder (stream and record) if (surfaceManagerEncoder.isReady && mainRender.isReady() && !limitFps) { + Logger.d(TAG, "draw: 1 surfaceManagerEncoder.isReady = ${surfaceManagerEncoder.isReady}, mainRender.isReady() = ${mainRender.isReady()}, limitFps = ${limitFps}") val w = if (muteVideo) 0 else encoderWidth val h = if (muteVideo) 0 else encoderHeight if (surfaceManagerEncoder.makeCurrent()) { @@ -269,6 +263,7 @@ class GlStreamInterface(private val context: Context): OnFrameAvailableListener, } // render VideoEncoder (record if the resolution is different than stream) if (surfaceManagerEncoderRecord.isReady && mainRender.isReady() && !limitFps) { + Logger.d(TAG, "draw: 2 surfaceManagerEncoderRecord.isReady = ${surfaceManagerEncoderRecord.isReady}, mainRender.isReady() = ${mainRender.isReady()}, limitFps = ${limitFps}") val w = if (muteVideo) 0 else encoderRecordWidth val h = if (muteVideo) 0 else encoderRecordHeight if (surfaceManagerEncoderRecord.makeCurrent()) { @@ -279,16 +274,17 @@ class GlStreamInterface(private val context: Context): OnFrameAvailableListener, } //render surface photo if request photo if (takePhotoCallback != null && surfaceManagerPhoto.isReady && mainRender.isReady()) { - if (surfaceManagerPhoto.makeCurrent()) { - mainRender.drawScreen(encoderWidth, encoderHeight, AspectRatioMode.NONE, - streamOrientation, isStreamVerticalFlip, isStreamHorizontalFlip, streamViewPort) - takePhotoCallback?.onTakePhoto(GlUtil.getBitmap(encoderWidth, encoderHeight)) - takePhotoCallback = null - surfaceManagerPhoto.swapBuffer() - } + Logger.d(TAG, "draw: 3 takePhotoCallback = ${takePhotoCallback}, surfaceManagerPhoto.isReady = ${surfaceManagerPhoto.isReady}, mainRender.isReady() = ${mainRender.isReady()}") + surfaceManagerPhoto.makeCurrent() + mainRender.drawScreen(encoderWidth, encoderHeight, AspectRatioMode.NONE, + streamOrientation, isStreamVerticalFlip, isStreamHorizontalFlip) + takePhotoCallback?.onTakePhoto(GlUtil.getBitmap(encoderWidth, encoderHeight)) + takePhotoCallback = null + surfaceManagerPhoto.swapBuffer() } // render preview if (surfaceManagerPreview.isReady && mainRender.isReady() && !limitFps) { + Logger.d(TAG, "draw: 4 surfaceManagerPreview.isReady = ${surfaceManagerPreview.isReady}, mainRender.isReady() = ${mainRender.isReady()}, limitFps = ${limitFps}") val w = if (previewWidth == 0) encoderWidth else previewWidth val h = if (previewHeight == 0) encoderHeight else previewHeight if (surfaceManager.makeCurrent()) { @@ -325,6 +321,7 @@ class GlStreamInterface(private val context: Context): OnFrameAvailableListener, } override fun onFrameAvailable(surfaceTexture: SurfaceTexture?) { + Logger.d(TAG, "onFrameAvailable: isRunning = $isRunning") if (!isRunning) return executor?.execute { try { @@ -354,6 +351,7 @@ class GlStreamInterface(private val context: Context): OnFrameAvailableListener, } fun forceOrientation(forced: OrientationForced) { + Logger.d(TAG, "forceOrientation: forced = $forced") when (forced) { OrientationForced.PORTRAIT -> { setCameraOrientation(90) @@ -365,6 +363,7 @@ class GlStreamInterface(private val context: Context): OnFrameAvailableListener, } OrientationForced.NONE -> { val orientation = CameraHelper.getCameraOrientation(context) + Logger.d(TAG, "forceOrientation: orientation = $orientation") setCameraOrientation(if (orientation == 0) 270 else orientation - 90) shouldHandleOrientation = true } @@ -464,6 +463,7 @@ class GlStreamInterface(private val context: Context): OnFrameAvailableListener, } fun setCameraOrientation(orientation: Int) { + Logger.d(TAG, "setCameraOrientation: orientation = $orientation") mainRender.setCameraRotation(orientation) } diff --git a/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManager.kt b/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManager.kt index ac32b761d..3ac4d5d0e 100644 --- a/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManager.kt +++ b/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManager.kt @@ -41,8 +41,10 @@ import java.io.IOException * Created by pedro on 21/04/21. */ abstract class CommandsManager { - - protected val TAG = "CommandsManager" + companion object { + private const val TAG = "CommandsManager" + } +// protected val TAG = "CommandsManager" val sessionHistory = CommandSessionHistory() var timestamp = 0 diff --git a/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf0.kt b/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf0.kt index 0a10dec25..514e463ae 100644 --- a/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf0.kt +++ b/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf0.kt @@ -32,9 +32,14 @@ import com.pedro.rtmp.rtmp.chunk.ChunkType import com.pedro.rtmp.rtmp.message.BasicHeader import com.pedro.rtmp.rtmp.message.command.CommandAmf0 import com.pedro.rtmp.rtmp.message.data.DataAmf0 +import com.pedro.rtmp.utils.Logger import com.pedro.rtmp.utils.socket.RtmpSocket class CommandsManagerAmf0: CommandsManager() { + companion object { + private const val TAG = "CommandsManagerAmf0" + } + override suspend fun sendConnectImp(auth: String, socket: RtmpSocket) { val connect = CommandAmf0("connect", ++commandId, getCurrentTimestamp(), streamId, BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)) @@ -109,6 +114,10 @@ class CommandsManagerAmf0: CommandsManager() { if (!videoDisabled) { amfEcmaArray.setProperty("width", width.toDouble()) amfEcmaArray.setProperty("height", height.toDouble()) +// amfEcmaArray.setProperty("width", height.toDouble()) +// amfEcmaArray.setProperty("height", width.toDouble()) + Logger.d(TAG, "sendMetadataImp: width = $width, height = $height") + //few servers don't support it even if it is in the standard rtmp enhanced val codecValue = when (videoCodec) { VideoCodec.H264 -> VideoFormat.AVC.value diff --git a/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf3.kt b/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf3.kt index 060c21054..fa9bc8889 100644 --- a/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf3.kt +++ b/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf3.kt @@ -31,9 +31,14 @@ import com.pedro.rtmp.rtmp.chunk.ChunkType import com.pedro.rtmp.rtmp.message.BasicHeader import com.pedro.rtmp.rtmp.message.command.CommandAmf3 import com.pedro.rtmp.rtmp.message.data.DataAmf3 +import com.pedro.rtmp.utils.Logger import com.pedro.rtmp.utils.socket.RtmpSocket class CommandsManagerAmf3: CommandsManager() { + companion object { + private const val TAG = "CommandsManagerAmf3" + } + override suspend fun sendConnectImp(auth: String, socket: RtmpSocket) { val connect = CommandAmf3("connect", ++commandId, getCurrentTimestamp(), streamId, BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)) @@ -101,6 +106,7 @@ class CommandsManagerAmf3: CommandsManager() { val amfEcmaArray = Amf3Dictionary() amfEcmaArray.setProperty("duration", 0.0) if (!videoDisabled) { + Logger.d(TAG, "sendMetadataImp: width = $width, height = $height") amfEcmaArray.setProperty("width", width.toDouble()) amfEcmaArray.setProperty("height", height.toDouble()) //few servers don't support it even if it is in the standard rtmp enhanced diff --git a/rtmp/src/main/java/com/pedro/rtmp/utils/Logger.kt b/rtmp/src/main/java/com/pedro/rtmp/utils/Logger.kt new file mode 100644 index 000000000..1db323e64 --- /dev/null +++ b/rtmp/src/main/java/com/pedro/rtmp/utils/Logger.kt @@ -0,0 +1,72 @@ +package com.pedro.rtmp.utils + +import android.util.Log + + +object Logger { + private const val TAG = "HUANG" + private const val ERROR = "ANDROID_ERROR:" + private const val WARN = "ANDROID_WARN:" + private const val RUNTIME_EXCEPTION = "ANDROID_RUNTIME_EXCEPTION" +// private val DEBUG = BuildConfig.DEBUG + private val DEBUG = true + + @JvmStatic + fun v(subTag: String, message: String){ + if (DEBUG) { + Log.v(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun d(subTag: String, message: String){ + if (DEBUG) { + Log.d(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun i(subTag: String, message: String){ + if (DEBUG) { + Log.i(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $message") + } + } + @JvmStatic + fun w(subTag: String, message: String){ + if (DEBUG) { + Log.w(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $WARN $message") + } + } + @JvmStatic + fun e(subTag: String, message: String){ + if (DEBUG) { + Log.e(TAG, "[$subTag][Thread: ${Thread.currentThread().name}] $ERROR $message") + } + } + + @JvmStatic + fun throwRuntimeException(){ + RuntimeException(TAG).printStackTrace() + } + + @JvmStatic + fun printStackTrace(tag: String){ + //打印堆栈而不退出 + d(tag, Log.getStackTraceString(Throwable())) + + Exception("debug log").printStackTrace() + + for (i in Thread.currentThread().stackTrace){ + i(tag, i.toString()) + } + val runtimeException = RuntimeException() + runtimeException.fillInStackTrace() + + try { + i(tag, "----------------------throw NullPointerException----------------------") + throw NullPointerException() + } catch (nullPointer: NullPointerException) { + i(tag, "----------------------catch NullPointerException----------------------") + e(tag, Log.getStackTraceString(nullPointer)) + } + i(tag, "----------------------end NullPointerException ----------------------") + } +} \ No newline at end of file