diff --git a/app/src/main/java/io/github/thibaultbee/streampack/example/ApplicationConstants.kt b/app/src/main/java/io/github/thibaultbee/streampack/example/ApplicationConstants.kt new file mode 100644 index 0000000..33773ad --- /dev/null +++ b/app/src/main/java/io/github/thibaultbee/streampack/example/ApplicationConstants.kt @@ -0,0 +1,14 @@ +package io.github.thibaultbee.streampack.example + +import android.content.pm.ActivityInfo + +/** + * Application configuration. + */ +object ApplicationConstants { + /** + * Default application orientation. + * Also set in `AndroidManifest.xml` `android:screenOrientation` attribute. + */ + const val supportedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED +} \ No newline at end of file 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 4e3f685..b689ab3 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 @@ -3,40 +3,35 @@ package io.github.thibaultbee.streampack.example import android.Manifest import android.annotation.SuppressLint import android.content.pm.ActivityInfo -import android.media.AudioFormat -import android.media.MediaFormat import android.os.Bundle import android.util.Log -import android.util.Size +import androidx.activity.viewModels import androidx.annotation.RequiresPermission import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope -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 kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding + private val viewModel: MainViewModel by viewModels { + MainViewModelFactory(this.application) + } private val streamerRequiredPermissions = - listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + listOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + ) + /** + * A minimalist permission manager + */ @SuppressLint("MissingPermission") private val permissionsManager = PermissionsManager( this, @@ -61,42 +56,10 @@ class MainActivity : AppCompatActivity() { ) }) - /** - * The streamer is the central object of StreamPack. - * It is responsible for the capture audio and video and the streaming process. - * - * 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 { - // 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 { 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() + private val streamerLifeCycleObserver by lazy { StreamerActivityLifeCycleObserver(viewModel.streamer) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -116,79 +79,60 @@ class MainActivity : AppCompatActivity() { */ 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") + viewModel.startStream() } 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() + viewModel.stopStream() } } } } - 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) + // Bind events + viewModel.closedThrowableLiveData.observe(this) { + toast("Connection error: ${it.message}") + } + + viewModel.throwableLiveData.observe(this) { + toast("Error: ${it.message}") + } + + viewModel.isStreamingLiveData.observe(this) { isStreaming -> + if (isStreaming) { + lockOrientation() + } else { + unlockOrientation() + } + if (isStreaming) { + binding.liveButton.isChecked = true + } else if (viewModel.isTryingConnectionLiveData.value == true) { + binding.liveButton.isChecked = true + } else { + binding.liveButton.isChecked = false } } - // 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 - } + viewModel.isTryingConnectionLiveData.observe(this) { isWaitingForConnection -> + if (isWaitingForConnection) { + binding.liveButton.isChecked = true + } else if (viewModel.isStreamingLiveData.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() { @@ -202,7 +146,7 @@ class MainActivity : AppCompatActivity() { } private fun unlockOrientation() { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + requestedOrientation = ApplicationConstants.supportedOrientation } override fun onStart() { @@ -220,54 +164,32 @@ class MainActivity : AppCompatActivity() { private fun setAVSource() { // Set audio and video sources. lifecycleScope.launch { - streamer.setAudioSource(MicrophoneSourceFactory()) - streamer.setCameraId(this@MainActivity.defaultCameraId) + viewModel.setAudioSource() + viewModel.setCameraId(this@MainActivity.defaultCameraId) } } private fun setStreamerView() { lifecycleScope.launch { - binding.preview.setVideoSourceProvider(streamer) // Bind the streamer to the preview + binding.preview.setVideoSourceProvider(viewModel.streamer) // Bind the streamer to the preview } } @SuppressLint("MissingPermission") private fun configureStreamer() { - /** - * To get the parameters supported by the device, the [SingleStreamer] have a - * [SingleStreamer.getInfo] method. - */ - - /** - * There are other parameters in the [VideoConfig] such as: - * - bitrate - * - profile - * - level - * - gopSize - * They will be initialized with an appropriate default value. - */ - val videoConfig = VideoConfig( - mimeType = MediaFormat.MIMETYPE_VIDEO_AVC, resolution = Size(1280, 720), fps = 25 - ) - - /** - * There are other parameters in the [AudioConfig] such as: - * - byteFormat - * - enableEchoCanceler - * - enableNoiseSuppressor - * They will be initialized with an appropriate default value. - */ - val audioConfig = AudioConfig( - mimeType = MediaFormat.MIMETYPE_AUDIO_AAC, - sampleRate = 44100, - channelConfig = AudioFormat.CHANNEL_IN_STEREO - ) - lifecycleScope.launch { - streamer.setConfig(audioConfig, videoConfig) + viewModel.setAudioConfig() + viewModel.setVideoConfig() } } + override fun onDestroy() { + super.onDestroy() + + // Unregister the lifecycle observer + lifecycle.removeObserver(streamerLifeCycleObserver) + } + private fun toast(message: String) { runOnUiThread { applicationContext.toast(message) } } diff --git a/app/src/main/java/io/github/thibaultbee/streampack/example/MainViewModel.kt b/app/src/main/java/io/github/thibaultbee/streampack/example/MainViewModel.kt new file mode 100644 index 0000000..b5b084a --- /dev/null +++ b/app/src/main/java/io/github/thibaultbee/streampack/example/MainViewModel.kt @@ -0,0 +1,156 @@ +package io.github.thibaultbee.streampack.example + +import android.Manifest +import android.media.AudioFormat +import android.media.MediaFormat +import android.util.Size +import androidx.annotation.RequiresPermission +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +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.interfaces.setCameraId +import io.github.thibaultbee.streampack.core.interfaces.startStream +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch + +class MainViewModel( + private val rotationRepository: RotationRepository, + val streamer: SingleStreamer +) : ViewModel() { + private val defaultDispatcher = Dispatchers.Default + + /** + * A LiveData to observe the stream state. + */ + val isStreamingLiveData: LiveData + get() = streamer.isStreamingFlow.asLiveData() + + /** + * A LiveData to observe the pending connection state. + */ + private val _isTryingConnectionLiveData = MutableLiveData() + val isTryingConnectionLiveData: LiveData = _isTryingConnectionLiveData + + /** + * A LiveData to observe async disconnection errors. + */ + val closedThrowableLiveData: LiveData = + streamer.throwableFlow.filterNotNull().filter { it.isClosedException }.asLiveData() + + /** + * A LiveData to observe streamer errors. + */ + val throwableLiveData: LiveData = + streamer.throwableFlow.filterNotNull().filter { !it.isClosedException }.asLiveData() + + + init { + /** + * Listens to device rotation. + */ + viewModelScope.launch(defaultDispatcher) { + rotationRepository.rotationFlow.collect { + streamer.setTargetRotation(it) + } + } + } + + /** + * Starts the stream. + * + * Replace with a valid URL. + */ + suspend fun startStream() { + _isTryingConnectionLiveData.postValue(true) + try { + /** + * For SRT, use srt://my.server.url:9998?streamid=myStreamId&passphrase=myPassphrase + */ + streamer.startStream("rtmp://my.server.url:1935/app/streamKey") + } finally { + _isTryingConnectionLiveData.postValue(false) + } + } + + /** + * Stops the stream. + */ + suspend fun stopStream() { + streamer.stopStream() + } + + /** + * Sets the audio configuration. + * + * You can verify the device supported configuration with [SingleStreamer.getInfo]. + */ + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + suspend fun setAudioConfig() { + /** + * There are other parameters in the [AudioConfig] such as: + * - byteFormat + * - enableEchoCanceler + * - enableNoiseSuppressor + * They will be initialized with an appropriate default value. + */ + val audioConfig = AudioConfig( + mimeType = MediaFormat.MIMETYPE_AUDIO_AAC, + sampleRate = 44100, + channelConfig = AudioFormat.CHANNEL_IN_STEREO + ) + + streamer.setAudioConfig(audioConfig) + } + + /** + * Sets the video configuration. + * + * You can verify the device supported configuration with [SingleStreamer.getInfo]. + */ + suspend fun setVideoConfig() { + /** + * There are other parameters in the [VideoConfig] such as: + * - bitrate + * - profile + * - level + * - gopSize + * They will be initialized with an appropriate default value. + */ + val videoConfig = VideoConfig( + mimeType = MediaFormat.MIMETYPE_VIDEO_AVC, resolution = Size(1280, 720), fps = 25 + ) + + streamer.setVideoConfig(videoConfig) + } + + /** + * Sets the microphone as the audio source. + */ + suspend fun setAudioSource() { + streamer.setAudioSource(MicrophoneSourceFactory()) + } + + /** + * Sets the camera with the given id as the video source. + * + * @param cameraId The camera id. + */ + @RequiresPermission(Manifest.permission.CAMERA) + suspend fun setCameraId(cameraId: String) { + streamer.setCameraId(cameraId) + } + + companion object { + private const val TAG = "MainViewModel" + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/thibaultbee/streampack/example/MainViewModelFactory.kt b/app/src/main/java/io/github/thibaultbee/streampack/example/MainViewModelFactory.kt new file mode 100644 index 0000000..1b94f62 --- /dev/null +++ b/app/src/main/java/io/github/thibaultbee/streampack/example/MainViewModelFactory.kt @@ -0,0 +1,52 @@ +package io.github.thibaultbee.streampack.example + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.github.thibaultbee.streampack.app.data.rotation.RotationRepository +import io.github.thibaultbee.streampack.core.streamers.dual.DualStreamer +import io.github.thibaultbee.streampack.core.streamers.single.SingleStreamer + +/** + * Factory for constructing [MainViewModelFactory] from the [Application]. + */ +class MainViewModelFactory(private val application: Application) : + ViewModelProvider.Factory { + private val rotationRepository = RotationRepository.getInstance(application) + + @Suppress("UNCHECKED_CAST") + + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MainViewModel::class.java)) { + val streamer = createStreamer(application) + return MainViewModel(rotationRepository, streamer) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + + companion object { + /** + * Creates a streamer instance. + * + * The streamer is the central object of StreamPack. + * It is responsible for the capture audio and video and the streaming process. + * + * If you need only 1 output (live only or record only), use [SingleStreamer]. + * If you need 2 outputs (live and record), use [DualStreamer]. + */ + private fun createStreamer(application: Application): SingleStreamer { + // 1 output + return SingleStreamer( + application, withAudio = true, withVideo = true + ) + // 2 outputs: uncomment the line below + /* + DualStreamer( + this, + withAudio = true, + withVideo = true + ) + */ + } + } +} \ No newline at end of file