Compare commits
10 Commits
3b8c52d8b8
...
5740e3f22b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5740e3f22b | ||
|
|
529356a052 | ||
|
|
344f270a06 | ||
|
|
a50391f588 | ||
|
|
4e4e34ddbf | ||
|
|
5a0104a72e | ||
|
|
6b9facb5a8 | ||
|
|
8c7991ef4f | ||
|
|
35a467061f | ||
|
|
da6e0f033f |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Assemble
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Upload APKs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: apks
|
||||
path: |
|
||||
|
||||
@@ -18,5 +18,6 @@ See [StreamPack](https://github.com/ThibaultBee/StreamPack) for more settings an
|
||||
1. Click on "Use this template" to create a new repository from this template.
|
||||
2. Clone your new repository.
|
||||
3. Open the project with Android Studio.
|
||||
4. Set your RTMP or SRT server URL in `MainActivity.kt`.
|
||||
4. Replace default `rtmp://my.server.url:1935/app/streamKey` by your RTMP or SRT server URL in
|
||||
`MainViewModel.kt`.
|
||||
5. Run the application on a device or an emulator.
|
||||
@@ -1,62 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'io.github.thibaultbee.streampack.example'
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "io.github.thibaultbee.streampack.example"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
pickFirsts += ['**/*.so']
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.16.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||
|
||||
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'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||
}
|
||||
65
app/build.gradle.kts
Normal file
65
app/build.gradle.kts
Normal file
@@ -0,0 +1,65 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.github.thibaultbee.streampack.app"
|
||||
compileSdk {
|
||||
version = release(36)
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "io.github.thibaultbee.streampack.app"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_18
|
||||
targetCompatibility = JavaVersion.VERSION_18
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_18)
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.streampack.core)
|
||||
// For the `PreviewView`
|
||||
implementation(libs.streampack.ui)
|
||||
// TODO: Only needed for RTMP live streaming: remove if you don't need it
|
||||
implementation(libs.streampack.rtmp)
|
||||
// TODO: Only needed for SRT live streaming: remove if you don't need it
|
||||
implementation(libs.streampack.srt)
|
||||
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.constraintlayout)
|
||||
implementation(libs.lifecycle.runtime.ktx)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.ext.junit)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
}
|
||||
@@ -14,8 +14,6 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Use internally. Not use with camera but it avoid a warning -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package io.github.thibaultbee.streampack.app
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package io.github.thibaultbee.streampack.app
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.github.thibaultbee.streampack.app.databinding.ActivityMainBinding
|
||||
import io.github.thibaultbee.streampack.core.elements.sources.video.camera.extensions.defaultCameraId
|
||||
import io.github.thibaultbee.streampack.core.streamers.lifecycle.StreamerActivityLifeCycleObserver
|
||||
import io.github.thibaultbee.streampack.app.utils.PermissionsManager
|
||||
import io.github.thibaultbee.streampack.app.utils.showDialog
|
||||
import io.github.thibaultbee.streampack.app.utils.toast
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* A minimalist permission manager
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private val permissionsManager = PermissionsManager(
|
||||
this,
|
||||
streamerRequiredPermissions,
|
||||
onAllGranted = { onPermissionsGranted() },
|
||||
onShowPermissionRationale = { permissions, onRequiredPermissionLastTime ->
|
||||
// Explain why we need permissions
|
||||
showDialog(
|
||||
title = "Permissions denied",
|
||||
message = "Explain why you need to grant $permissions permissions to stream",
|
||||
positiveButtonText = R.string.accept,
|
||||
onPositiveButtonClick = { onRequiredPermissionLastTime() },
|
||||
negativeButtonText = R.string.denied
|
||||
)
|
||||
},
|
||||
onDenied = {
|
||||
showDialog(
|
||||
"Permissions denied",
|
||||
"You need to grant all permissions to stream",
|
||||
positiveButtonText = 0,
|
||||
negativeButtonText = 0
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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(viewModel.streamer) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
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 {
|
||||
viewModel.startStream()
|
||||
} catch (e: Exception) {
|
||||
binding.liveButton.isChecked = false
|
||||
Log.e(TAG, "Failed to connect", e)
|
||||
toast("Connection failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
viewModel.stopStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the lifecycle observer
|
||||
lifecycle.addObserver(streamerLifeCycleObserver)
|
||||
|
||||
// Configure the streamer
|
||||
configureStreamer()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = ApplicationConstants.supportedOrientation
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
permissionsManager.requestPermissions()
|
||||
}
|
||||
|
||||
@RequiresPermission(allOf = [Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO])
|
||||
private fun onPermissionsGranted() {
|
||||
setAVSource()
|
||||
setStreamerView()
|
||||
}
|
||||
|
||||
@RequiresPermission(allOf = [Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO])
|
||||
private fun setAVSource() {
|
||||
// Set audio and video sources.
|
||||
lifecycleScope.launch {
|
||||
viewModel.setAudioSource()
|
||||
viewModel.setCameraId(this@MainActivity.defaultCameraId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setStreamerView() {
|
||||
lifecycleScope.launch {
|
||||
binding.preview.setVideoSourceProvider(viewModel.streamer) // Bind the streamer to the preview
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun configureStreamer() {
|
||||
lifecycleScope.launch {
|
||||
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) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package io.github.thibaultbee.streampack.app
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package io.github.thibaultbee.streampack.app
|
||||
|
||||
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
|
||||
)
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.github.thibaultbee.streampack.example.utils
|
||||
package io.github.thibaultbee.streampack.app.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.github.thibaultbee.streampack.example.utils
|
||||
package io.github.thibaultbee.streampack.app.utils
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -1,279 +0,0 @@
|
||||
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.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 streamerRequiredPermissions =
|
||||
listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private val permissionsManager = PermissionsManager(
|
||||
this,
|
||||
streamerRequiredPermissions,
|
||||
onAllGranted = { onPermissionsGranted() },
|
||||
onShowPermissionRationale = { permissions, onRequiredPermissionLastTime ->
|
||||
// Explain why we need permissions
|
||||
showDialog(
|
||||
title = "Permissions denied",
|
||||
message = "Explain why you need to grant $permissions permissions to stream",
|
||||
positiveButtonText = R.string.accept,
|
||||
onPositiveButtonClick = { onRequiredPermissionLastTime() },
|
||||
negativeButtonText = R.string.denied
|
||||
)
|
||||
},
|
||||
onDenied = {
|
||||
showDialog(
|
||||
"Permissions denied",
|
||||
"You need to grant all permissions to stream",
|
||||
positiveButtonText = 0,
|
||||
negativeButtonText = 0
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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<Boolean>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
super.onStart()
|
||||
permissionsManager.requestPermissions()
|
||||
}
|
||||
|
||||
@RequiresPermission(allOf = [Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO])
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(message: String) {
|
||||
runOnUiThread { applicationContext.toast(message) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
}
|
||||
}
|
||||
12
build.gradle
12
build.gradle
@@ -1,12 +0,0 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext {
|
||||
// Upgrade StreamPack version here
|
||||
streampackVersion = '3.0.0-RC'
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'com.android.application' version '8.9.1' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
|
||||
}
|
||||
5
build.gradle.kts
Normal file
5
build.gradle.kts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
}
|
||||
@@ -22,3 +22,13 @@ kotlin.code.style=official
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=false
|
||||
android.defaults.buildfeatures.resvalues=true
|
||||
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
|
||||
android.enableAppCompileTimeRClass=false
|
||||
android.usesSdkInManifest.disallowed=false
|
||||
android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=true
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.r8.optimizedResourceShrinking=false
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
31
gradle/libs.versions.toml
Normal file
31
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[versions]
|
||||
agp = "9.0.0"
|
||||
appcompat = "1.7.1"
|
||||
constraintlayout = "2.2.1"
|
||||
coreKtx = "1.17.0"
|
||||
espressoCore = "3.7.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
kotlin = "2.3.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
material = "1.13.0"
|
||||
streampack = "3.1.1"
|
||||
|
||||
[libraries]
|
||||
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
||||
constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
||||
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" }
|
||||
ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||
streampack-core = { group = "io.github.thibaultbee.streampack", name = "streampack-core", version.ref = "streampack" }
|
||||
streampack-ui = { group = "io.github.thibaultbee.streampack", name = "streampack-ui", version.ref = "streampack" }
|
||||
streampack-rtmp = { group = "io.github.thibaultbee.streampack", name = "streampack-rtmp", version.ref = "streampack" }
|
||||
streampack-srt = { group = "io.github.thibaultbee.streampack", name = "streampack-srt", version.ref = "streampack" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Sun Mar 26 13:16:11 CEST 2023
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
@@ -12,5 +18,6 @@ dependencyResolutionManagement {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "MyStreamingApp"
|
||||
include ':app'
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user