Documentation Index
Fetch the complete documentation index at: https://learn.social.plus/llms.txt
Use this file to discover all available pages before exploring further.
Android Implementation Guide
Complete guide for implementing social.plus Video SDK in Android applications using Kotlin and modern Android development practices.Overview
This guide covers Android-specific implementation details, native components integration, and platform-specific considerations for video streaming applications.Prerequisites
- Android 5.0 (API 21)+ minimum SDK version
- Android Studio 4.2+ for development
- Kotlin 1.5+ support
- Gradle 7.0+ build system
Core Dependencies
// App-level build.gradle
dependencies {
implementation 'com.github.AmityCo.Amity-Social-Cloud-SDK-Android:amity-video-publisher:1.0.0'
implementation 'com.github.AmityCo.Amity-Social-Cloud-SDK-Android:amity-video-player:1.0.0'
implementation 'com.github.AmityCo.Amity-Social-Cloud-SDK-Android:amity-video-core:1.0.0'
// Required Android dependencies
implementation 'androidx.camera:camera-core:1.3.1'
implementation 'androidx.camera:camera-camera2:1.3.1'
implementation 'androidx.camera:camera-lifecycle:1.3.1'
implementation 'androidx.camera:camera-view:1.3.1'
implementation 'androidx.media3:media3-exoplayer:1.2.0'
implementation 'androidx.media3:media3-ui:1.2.0'
// Coroutines for async operations
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// Lifecycle components
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
}
Installation & Setup
Gradle Configuration
// Project-level build.gradle
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
maven { url 'https://releases.social.plus/android' }
}
}
// App-level build.gradle
android {
compileSdk 34
defaultConfig {
minSdk 21
targetSdk 34
// Required for video features
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding true
dataBinding true
}
}
Application Setup
// Application class
class VideoApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize social.plus Video SDK
setupVideoSDK()
}
private fun setupVideoSDK() {
val config = AmityCoreClient.Configuration(
apiKey = "your-api-key",
region = AmityRegion.GLOBAL
)
AmityCoreClient.setup(this, config)
// Register video publisher
AmityStreamBroadcasterClient.setup(AmityCoreClient.getConfiguration())
}
}
Manifest Configuration
<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Camera hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<application
android:name=".VideoApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme">
<!-- Your activities -->
</application>
</manifest>
Permissions & Runtime Handling
Permission Manager
class PermissionManager(private val activity: Activity) {
companion object {
private const val PERMISSION_REQUEST_CODE = 1001
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
}
fun requestPermissions(callback: (Boolean) -> Unit) {
if (hasAllPermissions()) {
callback(true)
return
}
this.permissionCallback = callback
ActivityCompat.requestPermissions(
activity,
REQUIRED_PERMISSIONS,
PERMISSION_REQUEST_CODE
)
}
private fun hasAllPermissions(): Boolean {
return REQUIRED_PERMISSIONS.all { permission ->
ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED
}
}
private var permissionCallback: ((Boolean) -> Unit)? = null
fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
if (requestCode == PERMISSION_REQUEST_CODE) {
val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
permissionCallback?.invoke(allGranted)
permissionCallback = null
}
}
fun showPermissionRationale(callback: () -> Unit) {
AlertDialog.Builder(activity)
.setTitle("Permissions Required")
.setMessage("This app needs camera and microphone access to broadcast video")
.setPositiveButton("Grant") { _, _ -> callback() }
.setNegativeButton("Cancel", null)
.show()
}
}
Broadcasting Implementation
Broadcast Activity
class LiveBroadcastActivity : AppCompatActivity() {
private lateinit var binding: ActivityLiveBroadcastBinding
private lateinit var broadcaster: AmityStreamBroadcaster
private lateinit var permissionManager: PermissionManager
private var streamId: String? = null
private var isLive = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLiveBroadcastBinding.inflate(layoutInflater)
setContentView(binding.root)
permissionManager = PermissionManager(this)
setupUI()
requestPermissions()
}
private fun setupUI() {
binding.apply {
startButton.setOnClickListener { startBroadcast() }
stopButton.setOnClickListener { stopBroadcast() }
switchCameraButton.setOnClickListener { switchCamera() }
toggleMuteButton.setOnClickListener { toggleMute() }
qualityButton.setOnClickListener { showQualitySelector() }
}
}
private fun requestPermissions() {
permissionManager.requestPermissions { granted ->
if (granted) {
setupBroadcaster()
} else {
showPermissionDeniedDialog()
}
}
}
private fun setupBroadcaster() {
try {
// Create broadcaster configuration
val config = AmityStreamBroadcasterConfiguration.Builder()
.setOrientation(Configuration.ORIENTATION_PORTRAIT)
.setResolution(AmityBroadcastResolution.HD_720P)
.build()
// Create broadcaster
broadcaster = AmityStreamBroadcaster.Builder(binding.cameraView)
.setConfiguration(config)
.build()
// Set up event listeners
broadcaster.setOnStateChangedListener { state ->
runOnUiThread { updateUI(state) }
}
broadcaster.setOnErrorListener { error ->
runOnUiThread { handleBroadcastError(error) }
}
updateUI(AmityStreamBroadcasterState.IDLE)
} catch (e: Exception) {
Log.e("Broadcast", "Failed to setup broadcaster", e)
showError("Failed to initialize broadcaster: ${e.message}")
}
}
private fun startBroadcast() {
if (!::broadcaster.isInitialized) {
showError("Broadcaster not initialized")
return
}
val title = binding.titleEditText.text.toString().ifEmpty { "Live Stream" }
val description = binding.descriptionEditText.text.toString().ifEmpty { "Broadcasting live from Android" }
broadcaster.startPublish(title, description)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ stream ->
streamId = stream.streamId
isLive = true
updateUI(AmityStreamBroadcasterState.CONNECTED)
// Track analytics
trackBroadcastStarted(stream.streamId)
}, { error ->
Log.e("Broadcast", "Failed to start broadcast", error)
handleBroadcastError(error)
})
}
private fun stopBroadcast() {
if (!::broadcaster.isInitialized || !isLive) return
broadcaster.stopPublish()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
isLive = false
streamId = null
updateUI(AmityStreamBroadcasterState.IDLE)
// Track analytics
trackBroadcastStopped()
}, { error ->
Log.e("Broadcast", "Failed to stop broadcast", error)
handleBroadcastError(error)
})
}
private fun switchCamera() {
if (!::broadcaster.isInitialized) return
broadcaster.switchCamera()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
// Camera switched successfully
}, { error ->
Log.e("Broadcast", "Failed to switch camera", error)
showError("Failed to switch camera")
})
}
private fun toggleMute() {
if (!::broadcaster.isInitialized) return
val isMuted = broadcaster.isMuted()
broadcaster.setMuted(!isMuted)
binding.toggleMuteButton.apply {
text = if (!isMuted) "Unmute" else "Mute"
setCompoundDrawablesWithIntrinsicBounds(
if (!isMuted) R.drawable.ic_mic_off else R.drawable.ic_mic_on,
0, 0, 0
)
}
}
private fun showQualitySelector() {
val qualities = arrayOf("480p", "720p", "1080p")
val resolutions = arrayOf(
AmityBroadcastResolution.SD_480P,
AmityBroadcastResolution.HD_720P,
AmityBroadcastResolution.FHD_1080P
)
AlertDialog.Builder(this)
.setTitle("Select Quality")
.setItems(qualities) { _, which ->
updateBroadcastQuality(resolutions[which])
}
.show()
}
private fun updateBroadcastQuality(resolution: AmityBroadcastResolution) {
if (isLive) {
showError("Cannot change quality while broadcasting")
return
}
// Update configuration
val config = AmityStreamBroadcasterConfiguration.Builder()
.setOrientation(Configuration.ORIENTATION_PORTRAIT)
.setResolution(resolution)
.build()
broadcaster.updateConfiguration(config)
}
private fun updateUI(state: AmityStreamBroadcasterState) {
binding.apply {
when (state) {
AmityStreamBroadcasterState.IDLE -> {
statusText.text = "Ready to broadcast"
startButton.isEnabled = true
stopButton.isEnabled = false
liveIndicator.visibility = View.GONE
}
AmityStreamBroadcasterState.CONNECTING -> {
statusText.text = "Connecting..."
startButton.isEnabled = false
stopButton.isEnabled = false
liveIndicator.visibility = View.VISIBLE
}
AmityStreamBroadcasterState.CONNECTED -> {
statusText.text = "LIVE"
startButton.isEnabled = false
stopButton.isEnabled = true
liveIndicator.visibility = View.VISIBLE
}
AmityStreamBroadcasterState.DISCONNECTED -> {
statusText.text = "Disconnected"
startButton.isEnabled = true
stopButton.isEnabled = false
liveIndicator.visibility = View.GONE
}
}
}
}
private fun handleBroadcastError(error: Throwable) {
val message = when (error) {
is AmityNetworkException -> "Network error. Please check your connection."
is AmityPermissionException -> "Permission denied. Please grant required permissions."
is AmityStreamException -> "Stream error: ${error.message}"
else -> "Broadcast error: ${error.message}"
}
showError(message)
// Reset UI state
isLive = false
updateUI(AmityStreamBroadcasterState.IDLE)
}
private fun showError(message: String) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
private fun showPermissionDeniedDialog() {
AlertDialog.Builder(this)
.setTitle("Permissions Required")
.setMessage("Camera and microphone permissions are required for broadcasting")
.setPositiveButton("Grant") { _, _ ->
permissionManager.requestPermissions { granted ->
if (granted) setupBroadcaster()
else finish()
}
}
.setNegativeButton("Cancel") { _, _ -> finish() }
.setCancelable(false)
.show()
}
private fun trackBroadcastStarted(streamId: String) {
// Analytics tracking
val properties = mapOf(
"stream_id" to streamId,
"platform" to "android",
"quality" to broadcaster.configuration.resolution.name
)
// Send to your analytics service
// Analytics.track("broadcast_started", properties)
}
private fun trackBroadcastStopped() {
// Analytics tracking
// Analytics.track("broadcast_stopped")
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionManager.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
override fun onDestroy() {
super.onDestroy()
// Clean up broadcaster
if (::broadcaster.isInitialized) {
if (isLive) {
broadcaster.stopPublish().subscribe()
}
broadcaster.release()
}
}
}
Broadcast Layout
<!-- activity_live_broadcast.xml -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res/constraints"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<!-- Camera preview -->
<com.amity.socialcloud.sdk.video.AmityCameraView
android:id="@+id/cameraView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/controlsContainer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintDimensionRatio="H,9:16" />
<!-- Live indicator -->
<TextView
android:id="@+id/liveIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="LIVE"
android:textColor="@color/white"
android:textSize="16sp"
android:textStyle="bold"
android:background="@drawable/live_indicator_bg"
android:padding="8dp"
android:visibility="gone"
app:layout_constraintTop_toTopOf="@+id/cameraView"
app:layout_constraintStart_toStartOf="@+id/cameraView"
android:layout_margin="16dp" />
<!-- Top controls -->
<LinearLayout
android:id="@+id/topControls"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
app:layout_constraintTop_toTopOf="@+id/cameraView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<Button
android:id="@+id/switchCameraButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/circle_button_bg"
android:drawableTop="@drawable/ic_switch_camera"
android:layout_marginEnd="16dp" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<Button
android:id="@+id/qualityButton"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:text="720p"
android:textColor="@color/white"
android:background="@drawable/rounded_button_bg"
android:padding="12dp" />
</LinearLayout>
<!-- Controls container -->
<LinearLayout
android:id="@+id/controlsContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:background="@drawable/controls_bg"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<!-- Stream info inputs -->
<EditText
android:id="@+id/titleEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Stream title"
android:textColor="@color/white"
android:textColorHint="@color/white_70"
android:background="@drawable/edit_text_bg"
android:padding="12dp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/descriptionEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Description (optional)"
android:textColor="@color/white"
android:textColorHint="@color/white_70"
android:background="@drawable/edit_text_bg"
android:padding="12dp"
android:layout_marginBottom="16dp" />
<!-- Status text -->
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ready to broadcast"
android:textColor="@color/white"
android:textSize="16sp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp" />
<!-- Control buttons -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="center_horizontal">
<Button
android:id="@+id/toggleMuteButton"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="@drawable/circle_button_bg"
android:drawableTop="@drawable/ic_mic_on"
android:layout_marginEnd="24dp" />
<Button
android:id="@+id/startButton"
android:layout_width="80dp"
android:layout_height="80dp"
android:background="@drawable/broadcast_button_bg"
android:text="GO\nLIVE"
android:textColor="@color/white"
android:textSize="12sp"
android:textStyle="bold"
android:layout_marginEnd="24dp" />
<Button
android:id="@+id/stopButton"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="@drawable/stop_button_bg"
android:enabled="false" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
Video Playback Implementation
Player Activity
class VideoPlayerActivity : AppCompatActivity() {
private lateinit var binding: ActivityVideoPlayerBinding
private lateinit var player: AmityVideoPlayer
private var streamId: String? = null
private var isLiveStream = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityVideoPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
// Get stream info from intent
streamId = intent.getStringExtra("stream_id")
isLiveStream = intent.getBooleanExtra("is_live", false)
setupPlayer()
setupUI()
loadStream()
}
private fun setupPlayer() {
player = AmityVideoPlayer.Builder(this)
.setPlayerView(binding.playerView)
.build()
// Set up event listeners
player.setOnStateChangedListener { state ->
runOnUiThread { updateUI(state) }
}
player.setOnErrorListener { error ->
runOnUiThread { handlePlayerError(error) }
}
player.setOnProgressListener { position, duration ->
runOnUiThread { updateProgress(position, duration) }
}
}
private fun setupUI() {
binding.apply {
playButton.setOnClickListener { player.play() }
pauseButton.setOnClickListener { player.pause() }
fullscreenButton.setOnClickListener { toggleFullscreen() }
// Seek bar for VOD content
if (!isLiveStream) {
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
player.seekTo(progress.toLong())
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
} else {
seekBar.visibility = View.GONE
liveIndicator.visibility = View.VISIBLE
}
}
}
private fun loadStream() {
streamId?.let { id ->
player.loadStream(id)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ stream ->
updateStreamInfo(stream)
}, { error ->
Log.e("Player", "Failed to load stream", error)
handlePlayerError(error)
})
}
}
private fun updateStreamInfo(stream: AmityStream) {
binding.apply {
titleText.text = stream.title
descriptionText.text = stream.description
if (isLiveStream) {
viewerCountText.text = "${stream.viewerCount} viewers"
viewerCountText.visibility = View.VISIBLE
}
}
}
private fun updateUI(state: AmityVideoPlayerState) {
binding.apply {
when (state) {
AmityVideoPlayerState.IDLE -> {
playButton.visibility = View.VISIBLE
pauseButton.visibility = View.GONE
loadingIndicator.visibility = View.GONE
}
AmityVideoPlayerState.LOADING -> {
playButton.visibility = View.GONE
pauseButton.visibility = View.GONE
loadingIndicator.visibility = View.VISIBLE
}
AmityVideoPlayerState.PLAYING -> {
playButton.visibility = View.GONE
pauseButton.visibility = View.VISIBLE
loadingIndicator.visibility = View.GONE
}
AmityVideoPlayerState.PAUSED -> {
playButton.visibility = View.VISIBLE
pauseButton.visibility = View.GONE
loadingIndicator.visibility = View.GONE
}
AmityVideoPlayerState.ENDED -> {
playButton.visibility = View.VISIBLE
pauseButton.visibility = View.GONE
loadingIndicator.visibility = View.GONE
if (!isLiveStream) {
binding.seekBar.progress = 0
}
}
}
}
}
private fun updateProgress(position: Long, duration: Long) {
if (!isLiveStream && duration > 0) {
binding.apply {
seekBar.max = duration.toInt()
seekBar.progress = position.toInt()
currentTimeText.text = formatTime(position)
totalTimeText.text = formatTime(duration)
}
}
}
private fun formatTime(timeMs: Long): String {
val seconds = (timeMs / 1000) % 60
val minutes = (timeMs / (1000 * 60)) % 60
val hours = (timeMs / (1000 * 60 * 60))
return if (hours > 0) {
String.format("%02d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
}
private fun toggleFullscreen() {
if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
private fun handlePlayerError(error: Throwable) {
val message = when (error) {
is AmityNetworkException -> "Network error. Please check your connection."
is AmityStreamNotFoundException -> "Stream not found."
is AmityStreamUnavailableException -> "Stream is not available."
else -> "Playback error: ${error.message}"
}
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
override fun onDestroy() {
super.onDestroy()
player.release()
}
override fun onPause() {
super.onPause()
player.pause()
}
override fun onResume() {
super.onResume()
// Auto-resume for live streams
if (isLiveStream) {
player.play()
}
}
}
Push Notifications Integration
Firebase Messaging Service
class VideoFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d("FCM", "New token: $token")
// Register with social.plus Video SDK
VideoNotificationManager.getInstance().registerDevice(token) { result ->
when (result) {
is AmityResult.Success -> {
Log.d("FCM", "Device registered successfully")
}
is AmityResult.Error -> {
Log.e("FCM", "Failed to register device", result.exception)
}
}
}
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.d("FCM", "Message received from: ${remoteMessage.from}")
val data = remoteMessage.data
val eventType = data["event_type"]
val streamId = data["stream_id"]
when (eventType) {
"stream.started" -> handleStreamStarted(data)
"viewer.milestone" -> handleViewerMilestone(data)
"recording.ready" -> handleRecordingReady(data)
else -> handleGenericNotification(remoteMessage)
}
}
private fun handleStreamStarted(data: Map<String, String>) {
val streamId = data["stream_id"] ?: return
val broadcasterName = data["broadcaster_name"] ?: "Someone"
val streamTitle = data["stream_title"] ?: "Untitled Stream"
val notification = NotificationCompat.Builder(this, STREAM_CHANNEL_ID)
.setContentTitle("🔴 $broadcasterName is LIVE")
.setContentText(streamTitle)
.setSmallIcon(R.drawable.ic_live_stream)
.setAutoCancel(true)
.setContentIntent(createStreamPendingIntent(streamId, isLive = true))
.addAction(
R.drawable.ic_play,
"Watch",
createStreamPendingIntent(streamId, isLive = true)
)
.addAction(
R.drawable.ic_share,
"Share",
createSharePendingIntent(streamId)
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
NotificationManagerCompat.from(this).notify(streamId.hashCode(), notification)
}
private fun handleViewerMilestone(data: Map<String, String>) {
val streamId = data["stream_id"] ?: return
val viewerCount = data["viewer_count"] ?: return
val broadcasterName = data["broadcaster_name"] ?: "Your stream"
val notification = NotificationCompat.Builder(this, MILESTONE_CHANNEL_ID)
.setContentTitle("🎉 Milestone Reached!")
.setContentText("$viewerCount people are watching $broadcasterName")
.setSmallIcon(R.drawable.ic_celebration)
.setAutoCancel(true)
.setContentIntent(createStreamPendingIntent(streamId, isLive = true))
.build()
NotificationManagerCompat.from(this).notify(
"milestone_$streamId".hashCode(),
notification
)
}
private fun handleRecordingReady(data: Map<String, String>) {
val streamId = data["stream_id"] ?: return
val recordingUrl = data["recording_url"] ?: return
val notification = NotificationCompat.Builder(this, RECORDING_CHANNEL_ID)
.setContentTitle("Recording Ready")
.setContentText("Your stream recording is now available")
.setSmallIcon(R.drawable.ic_recording)
.setAutoCancel(true)
.setContentIntent(createRecordingPendingIntent(recordingUrl))
.addAction(
R.drawable.ic_play,
"Watch",
createRecordingPendingIntent(recordingUrl)
)
.build()
NotificationManagerCompat.from(this).notify(
"recording_$streamId".hashCode(),
notification
)
}
private fun createStreamPendingIntent(streamId: String, isLive: Boolean): PendingIntent {
val intent = Intent(this, VideoPlayerActivity::class.java).apply {
putExtra("stream_id", streamId)
putExtra("is_live", isLive)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
return PendingIntent.getActivity(
this,
streamId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createSharePendingIntent(streamId: String): PendingIntent {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "Watch this live stream: https://your-app.com/stream/$streamId")
}
val intent = Intent.createChooser(shareIntent, "Share Stream")
return PendingIntent.getActivity(
this,
"share_$streamId".hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createRecordingPendingIntent(recordingUrl: String): PendingIntent {
val intent = Intent(this, VideoPlayerActivity::class.java).apply {
putExtra("recording_url", recordingUrl)
putExtra("is_live", false)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
return PendingIntent.getActivity(
this,
recordingUrl.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
companion object {
private const val STREAM_CHANNEL_ID = "stream_notifications"
private const val MILESTONE_CHANNEL_ID = "milestone_notifications"
private const val RECORDING_CHANNEL_ID = "recording_notifications"
}
}
Notification Channels Setup
class NotificationChannelManager(private val context: Context) {
fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Stream notifications channel
val streamChannel = NotificationChannel(
STREAM_CHANNEL_ID,
"Stream Notifications",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for live stream events"
enableLights(true)
lightColor = Color.RED
enableVibration(true)
setSound(
Uri.parse("android.resource://${context.packageName}/raw/stream_notification"),
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
}
// Milestone notifications channel
val milestoneChannel = NotificationChannel(
MILESTONE_CHANNEL_ID,
"Milestone Notifications",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Notifications for viewer milestones"
enableLights(true)
lightColor = Color.BLUE
}
// Recording notifications channel
val recordingChannel = NotificationChannel(
RECORDING_CHANNEL_ID,
"Recording Notifications",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Notifications for recording events"
}
notificationManager.createNotificationChannels(
listOf(streamChannel, milestoneChannel, recordingChannel)
)
}
}
companion object {
private const val STREAM_CHANNEL_ID = "stream_notifications"
private const val MILESTONE_CHANNEL_ID = "milestone_notifications"
private const val RECORDING_CHANNEL_ID = "recording_notifications"
}
}
Jetpack Compose Integration
Compose Broadcasting Screen
@Composable
fun LiveBroadcastScreen(
viewModel: BroadcastViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.requestPermissions(context as Activity)
}
Box(modifier = Modifier.fillMaxSize()) {
// Camera preview
AndroidView(
factory = { context ->
AmityCameraView(context).apply {
viewModel.setupBroadcaster(this)
}
},
modifier = Modifier.fillMaxSize()
)
// Overlay UI
LiveBroadcastOverlay(
state = state,
onStartBroadcast = viewModel::startBroadcast,
onStopBroadcast = viewModel::stopBroadcast,
onSwitchCamera = viewModel::switchCamera,
onToggleMute = viewModel::toggleMute,
onQualityChange = viewModel::changeQuality
)
}
}
@Composable
fun LiveBroadcastOverlay(
state: BroadcastState,
onStartBroadcast: (String, String) -> Unit,
onStopBroadcast: () -> Unit,
onSwitchCamera: () -> Unit,
onToggleMute: () -> Unit,
onQualityChange: (AmityBroadcastResolution) -> Unit
) {
var showControls by remember { mutableStateOf(true) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Top controls
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = onSwitchCamera) {
Icon(
imageVector = Icons.Default.Cameraswitch,
contentDescription = "Switch Camera",
tint = Color.White
)
}
if (state.isLive) {
Card(
colors = CardDefaults.cardColors(containerColor = Color.Red),
modifier = Modifier.padding(8.dp)
) {
Text(
text = "LIVE",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
QualitySelector(
currentQuality = state.quality,
onQualityChange = onQualityChange
)
}
Spacer(modifier = Modifier.weight(1f))
// Bottom controls
if (showControls) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
if (!state.isLive) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Stream Title") },
colors = TextFieldDefaults.outlinedTextFieldColors(
textColor = Color.White,
focusedLabelColor = Color.White,
unfocusedLabelColor = Color.Gray
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (optional)") },
colors = TextFieldDefaults.outlinedTextFieldColors(
textColor = Color.White,
focusedLabelColor = Color.White,
unfocusedLabelColor = Color.Gray
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
}
Text(
text = state.statusText,
color = Color.White,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
IconButton(onClick = onToggleMute) {
Icon(
imageVector = if (state.isMuted) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (state.isMuted) "Unmute" else "Mute",
tint = Color.White
)
}
FloatingActionButton(
onClick = {
if (state.isLive) {
onStopBroadcast()
} else {
onStartBroadcast(
title.ifEmpty { "Live Stream" },
description
)
}
},
containerColor = if (state.isLive) Color.Red else Color.Green,
modifier = Modifier.size(80.dp)
) {
if (state.isConnecting) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier.size(24.dp)
)
} else {
Icon(
imageVector = if (state.isLive) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = if (state.isLive) "Stop" else "Start",
tint = Color.White
)
}
}
IconButton(onClick = { /* Settings */ }) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
tint = Color.White
)
}
}
}
}
}
}
}
@Composable
fun QualitySelector(
currentQuality: AmityBroadcastResolution,
onQualityChange: (AmityBroadcastResolution) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
TextButton(
onClick = { expanded = true },
colors = ButtonDefaults.textButtonColors(contentColor = Color.White)
) {
Text(
text = when (currentQuality) {
AmityBroadcastResolution.SD_480P -> "480p"
AmityBroadcastResolution.HD_720P -> "720p"
AmityBroadcastResolution.FHD_1080P -> "1080p"
}
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("480p") },
onClick = {
onQualityChange(AmityBroadcastResolution.SD_480P)
expanded = false
}
)
DropdownMenuItem(
text = { Text("720p") },
onClick = {
onQualityChange(AmityBroadcastResolution.HD_720P)
expanded = false
}
)
DropdownMenuItem(
text = { Text("1080p") },
onClick = {
onQualityChange(AmityBroadcastResolution.FHD_1080P)
expanded = false
}
)
}
}
}
Broadcast ViewModel
@HiltViewModel
class BroadcastViewModel @Inject constructor(
private val permissionManager: PermissionManager,
private val analyticsService: AnalyticsService
) : ViewModel() {
private val _state = MutableStateFlow(BroadcastState())
val state: StateFlow<BroadcastState> = _state.asStateFlow()
private var broadcaster: AmityStreamBroadcaster? = null
private var streamId: String? = null
fun requestPermissions(activity: Activity) {
permissionManager.requestPermissions(activity) { granted ->
if (granted) {
_state.value = _state.value.copy(permissionsGranted = true)
} else {
_state.value = _state.value.copy(error = "Permissions required for broadcasting")
}
}
}
fun setupBroadcaster(cameraView: AmityCameraView) {
try {
val config = AmityStreamBroadcasterConfiguration.Builder()
.setOrientation(Configuration.ORIENTATION_PORTRAIT)
.setResolution(AmityBroadcastResolution.HD_720P)
.build()
broadcaster = AmityStreamBroadcaster.Builder(cameraView)
.setConfiguration(config)
.build()
broadcaster?.setOnStateChangedListener { state ->
updateBroadcastState(state)
}
broadcaster?.setOnErrorListener { error ->
handleError(error)
}
_state.value = _state.value.copy(
isInitialized = true,
quality = AmityBroadcastResolution.HD_720P
)
} catch (e: Exception) {
_state.value = _state.value.copy(error = "Failed to initialize broadcaster: ${e.message}")
}
}
fun startBroadcast(title: String, description: String) {
broadcaster?.startPublish(title, description)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe({ stream ->
streamId = stream.streamId
analyticsService.trackBroadcastStarted(stream.streamId)
}, { error ->
handleError(error)
})
}
fun stopBroadcast() {
broadcaster?.stopPublish()
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe({
streamId?.let { id ->
analyticsService.trackBroadcastStopped(id)
}
streamId = null
}, { error ->
handleError(error)
})
}
fun switchCamera() {
broadcaster?.switchCamera()
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe({
// Camera switched successfully
}, { error ->
handleError(error)
})
}
fun toggleMute() {
broadcaster?.let { broadcaster ->
val isMuted = broadcaster.isMuted()
broadcaster.setMuted(!isMuted)
_state.value = _state.value.copy(isMuted = !isMuted)
}
}
fun changeQuality(quality: AmityBroadcastResolution) {
if (_state.value.isLive) {
_state.value = _state.value.copy(error = "Cannot change quality while broadcasting")
return
}
val config = AmityStreamBroadcasterConfiguration.Builder()
.setOrientation(Configuration.ORIENTATION_PORTRAIT)
.setResolution(quality)
.build()
broadcaster?.updateConfiguration(config)
_state.value = _state.value.copy(quality = quality)
}
private fun updateBroadcastState(broadcasterState: AmityStreamBroadcasterState) {
val newState = when (broadcasterState) {
AmityStreamBroadcasterState.IDLE -> _state.value.copy(
isLive = false,
isConnecting = false,
statusText = "Ready to broadcast"
)
AmityStreamBroadcasterState.CONNECTING -> _state.value.copy(
isConnecting = true,
statusText = "Connecting..."
)
AmityStreamBroadcasterState.CONNECTED -> _state.value.copy(
isLive = true,
isConnecting = false,
statusText = "LIVE"
)
AmityStreamBroadcasterState.DISCONNECTED -> _state.value.copy(
isLive = false,
isConnecting = false,
statusText = "Disconnected"
)
}
_state.value = newState
}
private fun handleError(error: Throwable) {
val message = when (error) {
is AmityNetworkException -> "Network error. Please check your connection."
is AmityPermissionException -> "Permission denied. Please grant required permissions."
else -> "Error: ${error.message}"
}
_state.value = _state.value.copy(
error = message,
isLive = false,
isConnecting = false
)
}
override fun onCleared() {
super.onCleared()
broadcaster?.release()
}
}
data class BroadcastState(
val isInitialized: Boolean = false,
val permissionsGranted: Boolean = false,
val isLive: Boolean = false,
val isConnecting: Boolean = false,
val isMuted: Boolean = false,
val quality: AmityBroadcastResolution = AmityBroadcastResolution.HD_720P,
val statusText: String = "Initializing...",
val error: String? = null
)
Performance Optimization
Memory Management
class VideoMemoryManager {
companion object {
fun optimizeForBroadcasting(context: Context) {
// Reduce memory usage for other components
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
if (memoryInfo.lowMemory) {
// Implement memory optimization strategies
System.gc()
// Reduce video quality if needed
// broadcastManager.reduceQuality()
}
}
fun cleanupVideoResources(broadcaster: AmityStreamBroadcaster?) {
broadcaster?.release()
}
fun cleanupPlayerResources(player: AmityVideoPlayer?) {
player?.release()
}
}
}
Background Handling
class VideoLifecycleObserver : LifecycleObserver {
private var broadcaster: AmityStreamBroadcaster? = null
private var player: AmityVideoPlayer? = null
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun onPause() {
// Pause video playback
player?.pause()
// Optionally pause broadcasting (based on requirements)
// broadcaster?.pause()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {
// Resume video playback for live streams
player?.play()
// Resume broadcasting if it was paused
// broadcaster?.resume()
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
// Clean up resources
broadcaster?.release()
player?.release()
}
fun setBroadcaster(broadcaster: AmityStreamBroadcaster) {
this.broadcaster = broadcaster
}
fun setPlayer(player: AmityVideoPlayer) {
this.player = player
}
}
Troubleshooting
Common Android Issues
-
Camera Access Denied
private fun handleCameraPermissionDenied() { if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) { // Show rationale and request again showPermissionRationale() } else { // User denied with "Don't ask again" - guide to settings showGoToSettingsDialog() } } -
Background Limitations
private fun handleBackgroundRestrictions() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { // Request to ignore battery optimizations for streaming requestIgnoreBatteryOptimizations() } } } -
Memory Issues
override fun onTrimMemory(level: Int) { super.onTrimMemory(level) when (level) { ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { // Reduce video quality or stop non-essential streams VideoMemoryManager.optimizeForBroadcasting(this) } ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { // App is in background, pause non-essential video operations pauseNonEssentialVideoOperations() } } }
Best Practices
- Permission Handling - Always check and request permissions appropriately
- Lifecycle Management - Properly handle Android lifecycle events
- Memory Management - Release video resources when not needed
- Background Limitations - Handle Android’s background processing limits
- Network Optimization - Implement adaptive streaming for varying network conditions
Next Steps
- iOS Implementation - iOS-specific implementation details
- Web Implementation - Web-specific implementation details
- Cross-Platform Comparison - Compare platform differences
Android Permissions: Ensure your app handles runtime permissions correctly and provides clear explanations for why permissions are needed. Target SDK 31+ requires careful handling of camera and microphone permissions.