feat(app): add a view model for a better architecture

This commit is contained in:
ThibaultBee
2026-01-28 11:58:18 +01:00
parent 4e4e34ddbf
commit a50391f588
4 changed files with 277 additions and 133 deletions

View File

@@ -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
}

View File

@@ -3,40 +3,35 @@ package io.github.thibaultbee.streampack.example
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.media.AudioFormat
import android.media.MediaFormat
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.util.Size import androidx.activity.viewModels
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope 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.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.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.databinding.ActivityMainBinding
import io.github.thibaultbee.streampack.example.utils.PermissionsManager import io.github.thibaultbee.streampack.example.utils.PermissionsManager
import io.github.thibaultbee.streampack.example.utils.showDialog import io.github.thibaultbee.streampack.example.utils.showDialog
import io.github.thibaultbee.streampack.example.utils.toast import io.github.thibaultbee.streampack.example.utils.toast
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private val viewModel: MainViewModel by viewModels {
MainViewModelFactory(this.application)
}
private val streamerRequiredPermissions = 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") @SuppressLint("MissingPermission")
private val permissionsManager = PermissionsManager( private val permissionsManager = PermissionsManager(
this, 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 * 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) } private val streamerLifeCycleObserver by lazy { StreamerActivityLifeCycleObserver(viewModel.streamer) }
/**
* Listen to device rotation.
*/
private val rotationRepository by lazy { RotationRepository.getInstance(applicationContext) }
/**
* A LiveData to observe the connection state.
*/
private val isTryingConnectionLiveData = MutableLiveData<Boolean>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -116,79 +79,60 @@ class MainActivity : AppCompatActivity() {
*/ */
lifecycleScope.launch { lifecycleScope.launch {
try { try {
isTryingConnectionLiveData.postValue(true) viewModel.startStream()
/**
* For SRT, use srt://my.server.url:9998?streamid=myStreamId&passphrase=myPassphrase
*/
streamer.startStream("rtmp://my.server.url:1935/app/streamKey")
} catch (e: Exception) { } catch (e: Exception) {
binding.liveButton.isChecked = false binding.liveButton.isChecked = false
Log.e(TAG, "Failed to connect", e) Log.e(TAG, "Failed to connect", e)
toast("Connection failed: ${e.message}") toast("Connection failed: ${e.message}")
} finally {
isTryingConnectionLiveData.postValue(false)
} }
} }
} else { } else {
lifecycleScope.launch { lifecycleScope.launch {
streamer.stopStream() viewModel.stopStream()
} }
} }
} }
} }
bindAndPrepareStreamer()
}
private fun bindAndPrepareStreamer() {
// Register the lifecycle observer // Register the lifecycle observer
lifecycle.addObserver(streamerLifeCycleObserver) lifecycle.addObserver(streamerLifeCycleObserver)
// Configure the streamer // Configure the streamer
configureStreamer() configureStreamer()
// Listen to rotation // Bind events
lifecycleScope.launch { viewModel.closedThrowableLiveData.observe(this) {
rotationRepository.rotationFlow.collect { toast("Connection error: ${it.message}")
streamer.setTargetRotation(it) }
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. viewModel.isTryingConnectionLiveData.observe(this) { isWaitingForConnection ->
lifecycleScope.launch { if (isWaitingForConnection) {
streamer.isStreamingFlow.collect { isStreaming -> binding.liveButton.isChecked = true
if (isStreaming) { } else if (viewModel.isStreamingLiveData.value == true) {
lockOrientation() binding.liveButton.isChecked = true
} else { } else {
unlockOrientation() binding.liveButton.isChecked = false
}
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() { private fun lockOrientation() {
@@ -202,7 +146,7 @@ class MainActivity : AppCompatActivity() {
} }
private fun unlockOrientation() { private fun unlockOrientation() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED requestedOrientation = ApplicationConstants.supportedOrientation
} }
override fun onStart() { override fun onStart() {
@@ -220,54 +164,32 @@ class MainActivity : AppCompatActivity() {
private fun setAVSource() { private fun setAVSource() {
// Set audio and video sources. // Set audio and video sources.
lifecycleScope.launch { lifecycleScope.launch {
streamer.setAudioSource(MicrophoneSourceFactory()) viewModel.setAudioSource()
streamer.setCameraId(this@MainActivity.defaultCameraId) viewModel.setCameraId(this@MainActivity.defaultCameraId)
} }
} }
private fun setStreamerView() { private fun setStreamerView() {
lifecycleScope.launch { 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") @SuppressLint("MissingPermission")
private fun configureStreamer() { 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 { 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) { private fun toast(message: String) {
runOnUiThread { applicationContext.toast(message) } runOnUiThread { applicationContext.toast(message) }
} }

View File

@@ -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<Boolean>
get() = streamer.isStreamingFlow.asLiveData()
/**
* A LiveData to observe the pending connection state.
*/
private val _isTryingConnectionLiveData = MutableLiveData<Boolean>()
val isTryingConnectionLiveData: LiveData<Boolean> = _isTryingConnectionLiveData
/**
* A LiveData to observe async disconnection errors.
*/
val closedThrowableLiveData: LiveData<Throwable> =
streamer.throwableFlow.filterNotNull().filter { it.isClosedException }.asLiveData()
/**
* A LiveData to observe streamer errors.
*/
val throwableLiveData: LiveData<Throwable> =
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"
}
}

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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
)
*/
}
}
}