diff --git a/app/build.gradle b/app/build.gradle index 70efbcc..aa997c8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,11 +47,13 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' - implementation "io.github.thibaultbee:streampack:${streampackVersion}" - // Only needed for RTMP live streaming - implementation "io.github.thibaultbee:streampack-extension-rtmp:${streampackVersion}" - // Only needed for SRT live streaming - implementation "io.github.thibaultbee:streampack-extension-srt:${streampackVersion}" + implementation "io.github.thibaultbee.streampack:streampack-core:${streampackVersion}" + // For the `PreviewView` + implementation "io.github.thibaultbee.streampack:streampack-ui:${streampackVersion}" + // TODO: Only needed for RTMP live streaming: remove if you don't need it + implementation "io.github.thibaultbee.streampack:streampack-extension-rtmp:${streampackVersion}" + // TODO: Only needed for SRT live streaming: remove if you don't need it + implementation "io.github.thibaultbee.streampack:streampack-extension-srt:${streampackVersion}" implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' testImplementation 'junit:junit:4.13.2' diff --git a/app/src/main/java/io/github/thibaultbee/streampack/example/MainActivity.kt b/app/src/main/java/io/github/thibaultbee/streampack/example/MainActivity.kt index c0662c8..9ea4672 100644 --- a/app/src/main/java/io/github/thibaultbee/streampack/example/MainActivity.kt +++ b/app/src/main/java/io/github/thibaultbee/streampack/example/MainActivity.kt @@ -10,20 +10,25 @@ import android.util.Log import android.util.Size import androidx.annotation.RequiresPermission import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope -import io.github.thibaultbee.streampack.data.AudioConfig -import io.github.thibaultbee.streampack.data.VideoConfig -import io.github.thibaultbee.streampack.error.StreamPackError +import io.github.thibaultbee.streampack.app.data.rotation.RotationRepository +import io.github.thibaultbee.streampack.core.elements.sources.audio.audiorecord.MicrophoneSourceFactory +import io.github.thibaultbee.streampack.core.elements.sources.video.camera.extensions.defaultCameraId +import io.github.thibaultbee.streampack.core.interfaces.setCameraId +import io.github.thibaultbee.streampack.core.interfaces.startStream +import io.github.thibaultbee.streampack.core.streamers.dual.DualStreamer +import io.github.thibaultbee.streampack.core.streamers.lifecycle.StreamerActivityLifeCycleObserver +import io.github.thibaultbee.streampack.core.streamers.single.AudioConfig +import io.github.thibaultbee.streampack.core.streamers.single.SingleStreamer +import io.github.thibaultbee.streampack.core.streamers.single.VideoConfig +import io.github.thibaultbee.streampack.core.utils.extensions.isClosedException import io.github.thibaultbee.streampack.example.databinding.ActivityMainBinding import io.github.thibaultbee.streampack.example.utils.PermissionsManager import io.github.thibaultbee.streampack.example.utils.showDialog import io.github.thibaultbee.streampack.example.utils.toast -import io.github.thibaultbee.streampack.ext.rtmp.streamers.CameraRtmpLiveStreamer -import io.github.thibaultbee.streampack.ext.srt.streamers.CameraSrtLiveStreamer -import io.github.thibaultbee.streampack.listeners.OnConnectionListener -import io.github.thibaultbee.streampack.listeners.OnErrorListener -import io.github.thibaultbee.streampack.streamers.StreamerLifeCycleObserver -import io.github.thibaultbee.streampack.streamers.helpers.CameraStreamerConfigurationHelper +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch class MainActivity : AppCompatActivity() { @@ -33,9 +38,10 @@ class MainActivity : AppCompatActivity() { listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) @SuppressLint("MissingPermission") - private val permissionsManager = PermissionsManager(this, + private val permissionsManager = PermissionsManager( + this, streamerRequiredPermissions, - onAllGranted = { inflateStreamer() }, + onAllGranted = { onPermissionsGranted() }, onShowPermissionRationale = { permissions, onRequiredPermissionLastTime -> // Explain why we need permissions showDialog( @@ -53,83 +59,150 @@ class MainActivity : AppCompatActivity() { positiveButtonText = 0, negativeButtonText = 0 ) - } - ) - - // Reports and manages error with [OnErrorListener] - private val errorListener = object : OnErrorListener { - override fun onError(error: StreamPackError) { - toast("An error occurred: $error") - } - } - - // Reports and manages connection events with [OnConnectionListener] - private val connectionListener = object : OnConnectionListener { - override fun onFailed(message: String) { - toast("Connection failed: $message") - } - - override fun onLost(message: String) { - toast("Connection lost: $message") - } - - override fun onSuccess() { - toast("Connected") - } - } + }) /** * The streamer is the central object of StreamPack. * It is responsible for the capture audio and video and the streaming process. * - * Use a [CameraRtmpLiveStreamer] For RTMP. Or a [CameraSrtLiveStreamer] for SRT. + * If you need only 1 output (live only or record only), use [SingleStreamer]. + * If you need 2 outputs (live and record), use [DualStreamer]. */ private val streamer by lazy { - CameraRtmpLiveStreamer( - this, - initialOnErrorListener = errorListener, - initialOnConnectionListener = connectionListener + // 1 output + SingleStreamer( + this, withAudio = true, withVideo = true ) + // 2 outputs: uncomment the line below + /* + DualStreamer( + this, + withAudio = true, + withVideo = true + ) + */ } /** * Listen to lifecycle events. So we don't have to stop the streamer manually in `onPause` and release in `onDestroy */ - private val streamerLifeCycleObserver by lazy { StreamerLifeCycleObserver(streamer) } + private val streamerLifeCycleObserver by lazy { StreamerActivityLifeCycleObserver(streamer) } + + /** + * Listen to device rotation. + */ + private val rotationRepository by lazy { RotationRepository.getInstance(applicationContext) } + + /** + * A LiveData to observe the connection state. + */ + private val isTryingConnectionLiveData = MutableLiveData() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - binding.liveButton.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - /** - * Dispatch from main thread is forced to avoid making network call on main thread - * with coroutines. - */ - lifecycleScope.launch { - try { - /** - * Always lock the device orientation during a live streaming to avoid - * to recreate the Activity. - */ - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED - /** - * For SRT, use srt://my.server.url:9998?streamid=myStreamId&passphrase=myPassphrase - */ - streamer.startStream("rtmp://my.server.url:1234/app/streamKey") - } catch (e: Exception) { - binding.liveButton.isChecked = false - Log.e(TAG, "Failed to connect", e) + + bindProperties() + } + + private fun bindProperties() { + binding.liveButton.setOnCheckedChangeListener { view, isChecked -> + if (view.isPressed) { + if (isChecked) { + /** + * Dispatch from main thread is forced to avoid making network call on main thread + * with coroutines. + */ + lifecycleScope.launch { + try { + isTryingConnectionLiveData.postValue(true) + /** + * For SRT, use srt://my.server.url:9998?streamid=myStreamId&passphrase=myPassphrase + */ + streamer.startStream("rtmp://my.server.url:1935/app/streamKey") + } catch (e: Exception) { + binding.liveButton.isChecked = false + Log.e(TAG, "Failed to connect", e) + toast("Connection failed: ${e.message}") + } finally { + isTryingConnectionLiveData.postValue(false) + } + } + } else { + lifecycleScope.launch { + streamer.stopStream() } - } - } else { - lifecycleScope.launch { - streamer.stopStream() - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } } } + + bindAndPrepareStreamer() + } + + private fun bindAndPrepareStreamer() { + // Register the lifecycle observer + lifecycle.addObserver(streamerLifeCycleObserver) + + // Configure the streamer + configureStreamer() + + // Listen to rotation + lifecycleScope.launch { + rotationRepository.rotationFlow.collect { + streamer.setTargetRotation(it) + } + } + + // Lock and unlock orientation on isStreaming state. + lifecycleScope.launch { + streamer.isStreamingFlow.collect { isStreaming -> + if (isStreaming) { + lockOrientation() + } else { + unlockOrientation() + } + if (isStreaming) { + binding.liveButton.isChecked = true + } else if (isTryingConnectionLiveData.value == true) { + binding.liveButton.isChecked = true + } else { + binding.liveButton.isChecked = false + } + } + } + + // General error handling + lifecycleScope.launch { + streamer.throwableFlow.filterNotNull().filter { !it.isClosedException } + .collect { throwable -> + Log.e(TAG, "Error: ${throwable.message}", throwable) + toast("Error: ${throwable.message}") + } + } + + // Connection error handling + lifecycleScope.launch { + streamer.throwableFlow.filterNotNull().filter { it.isClosedException } + .collect { throwable -> + Log.e(TAG, "Connection lost: ${throwable.message}", throwable) + toast("Connection lost: ${throwable.message}") + } + } + } + + private fun lockOrientation() { + /** + * Lock orientation while stream is running to avoid stream interruption if + * user turns the device. + * For landscape only mode, set [requireActivity().requestedOrientation] to + * [ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE]. + */ + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + } + + private fun unlockOrientation() { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } override fun onStart() { @@ -138,22 +211,32 @@ class MainActivity : AppCompatActivity() { } @RequiresPermission(allOf = [Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO]) - private fun inflateStreamer() { - lifecycle.addObserver(streamerLifeCycleObserver) // Register the lifecycle observer - /** - * Configure the streamer before calling view.streamer. Because it will start camera preview - * which required a configuration - */ - configureStreamer() + private fun onPermissionsGranted() { + setAVSource() + setStreamerView() + } + + @RequiresPermission(allOf = [Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO]) + private fun setAVSource() { + // Set audio and video sources. + lifecycleScope.launch { + streamer.setAudioSource(MicrophoneSourceFactory()) + streamer.setCameraId(this@MainActivity.defaultCameraId) + } + } + + private fun setStreamerView() { binding.preview.streamer = streamer // Bind the streamer to the preview + lifecycleScope.launch { + binding.preview.startPreview() + } } @SuppressLint("MissingPermission") private fun configureStreamer() { /** - * To check the parameters supported by the device, you can check parameter against: - * - [CameraStreamerConfigurationHelper.flvHelper] for RTMP live streaming or - * - [CameraStreamerConfigurationHelper.tsHelper] for SRT communication. + * To get the parameters supported by the device, the [SingleStreamer] have a + * [SingleStreamer.getInfo] method. */ /** @@ -180,9 +263,11 @@ class MainActivity : AppCompatActivity() { sampleRate = 44100, channelConfig = AudioFormat.CHANNEL_IN_STEREO ) - streamer.configure(audioConfig, videoConfig) - } + lifecycleScope.launch { + streamer.setConfig(audioConfig, videoConfig) + } + } private fun toast(message: String) { runOnUiThread { applicationContext.toast(message) } diff --git a/app/src/main/java/io/github/thibaultbee/streampack/example/data/rotation/RotationRepository.kt b/app/src/main/java/io/github/thibaultbee/streampack/example/data/rotation/RotationRepository.kt new file mode 100644 index 0000000..284f652 --- /dev/null +++ b/app/src/main/java/io/github/thibaultbee/streampack/example/data/rotation/RotationRepository.kt @@ -0,0 +1,40 @@ +package io.github.thibaultbee.streampack.app.data.rotation + +import android.content.Context +import io.github.thibaultbee.streampack.core.streamers.orientation.SensorRotationProvider +import io.github.thibaultbee.streampack.core.streamers.orientation.asFlowProvider +import kotlinx.coroutines.flow.Flow + +/** + * A repository for orientation data. + */ +class RotationRepository( + context: Context, +) { + /** + * A flow of device rotation. + * `SensorRotationProvider` follows the orientation of the sensor, so it will change when the + * device is rotated. + * If the application orientation is locked, you should use `DisplayRotationProvider` instead. + */ + //private val rotationProvider = DisplayRotationProvider(context).asFlowProvider() + private val rotationProvider = SensorRotationProvider(context).asFlowProvider() + val rotationFlow: Flow = rotationProvider.rotationFlow + + companion object { + @Volatile + private var INSTANCE: RotationRepository? = null + + fun getInstance(context: Context): RotationRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE?.let { + return it + } + + RotationRepository(context).apply { + INSTANCE = this + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 93fa5e3..91f9aa5 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -6,7 +6,7 @@ android:layout_height="match_parent" tools:context=".MainActivity"> -