HLS Quick Start for Android with Jetpack Compose
Build an Android app that broadcasts a VideoSDK room to a large audience over HTTP Live Streaming (HLS).
By the end of this guide, your app will support two roles:
- A host who creates or joins a room, publishes camera and microphone, and starts or stops HLS.
- A viewer who joins with the room ID and watches the HLS stream in a Media3 ExoPlayer.
You will build the app with Jetpack Compose in five Kotlin files. Each Kotlin code block is a complete file that you can copy into your project.
HLS scales playback to large audiences through HTTP delivery, but playback runs a few seconds behind. For interactive streaming under 100 ms, where a viewer can be promoted to speak, follow the Interactive Live Streaming guide instead.
Prefer XML layouts? The same app written with Activities and Fragments is in the HLS quick start.
Prerequisites
Before you begin, make sure you have:
- Android Studio Narwhal 3 Feature Drop (2025.1.3) or later.
- Android SDK API level 24 or later (
minSdk = 24). - A VideoSDK account and a temporary token from the VideoSDK dashboard.
- Two Android devices or emulators running Android 7.0 or later, one for the host and one for the viewer. Use ARM phones, or emulators with an
arm64-v8asystem image.
Use a dashboard token only while trying this quick start. In production, generate short-lived tokens on your server and fetch them from your app. Never add your VideoSDK API key or secret to the Android app. See Authentication and tokens.
Set up the project
Follow these steps to add HLS to your app.
Create an Android project
In Android Studio, create a Phone and Tablet project with the Empty Activity template. This template already uses Jetpack Compose and Kotlin DSL build files (build.gradle.kts), which is what this guide assumes.
After creating the project, Android Studio automatically starts gradle sync. Ensure that the sync succeeds before you continue.
Add the dependencies
Add VideoSDK to join the room and Media3 ExoPlayer for HLS playback. Both are available from the repositories included in a new Android Studio project.
- Kotlin DSL
- Groovy
- Make sure
google()andmavenCentral()are listed insettings.gradle.kts(a new project already has them).
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
- Add the versions and libraries to the version catalog.
[versions]
lifecycle = "2.8.7"
okhttp = "4.12.0"
rtcAndroidSdk = "3.0.0"
media3 = "1.8.0"
[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
rtc-android-sdk = { module = "live.videosdk:rtc-android-sdk", version.ref = "rtcAndroidSdk" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
- Add the dependencies to the app module.
dependencies {
// VideoSDK
implementation(libs.rtc.android.sdk)
// Creates a room through the VideoSDK REST API
implementation(libs.okhttp)
// Provides viewModel() for composables
implementation(libs.androidx.lifecycle.viewmodel.compose)
// HLS playback for the viewer
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
// Keep the dependencies generated by the template.
}
- Make sure
google()andmavenCentral()are listed insettings.gradle.
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
- Add the dependencies to the app module.
dependencies {
// VideoSDK
implementation "live.videosdk:rtc-android-sdk:3.0.0"
// Creates a room through the VideoSDK REST API
implementation "com.squareup.okhttp3:okhttp:4.12.0"
// Provides viewModel() for composables
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7"
// HLS playback for the viewer
implementation "androidx.media3:media3-exoplayer:1.8.0"
implementation "androidx.media3:media3-exoplayer-hls:1.8.0"
// Keep your existing dependencies.
}
The Android SDK runs on arm64-v8a and armeabi-v7a devices only. For an emulator, choose an arm64-v8a system image, for example on an Apple silicon Mac. x86 / x86_64 emulator images are not supported.
Configure the manifest
In app/src/main/AndroidManifest.xml, add the permissions and register the MainApplication class that you create in Step 1. The SDK doesn't add INTERNET or MODIFY_AUDIO_SETTINGS to your app, so declare them here.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:name=".MainApplication"
... >
<!-- the MainActivity generated by the template stays as it is -->
</application>
</manifest>
Create the project files
Create these five Kotlin files in the same package. The examples use live.videosdk.rtc.android.hlsquickstart; replace that package declaration with your app's package name. The sample uses MaterialTheme directly, so you can remove the template's ui/theme package if it is no longer referenced.
app/src/main/java/<your package>/
├── MainApplication.kt initializes VideoSDK and holds the token
├── HlsViewModel.kt creates or joins a room and controls HLS
├── ParticipantVideoView.kt renders one participant's camera, and the grid
├── HlsPlayerView.kt plays the stream for the viewer
└── MainActivity.kt Join screen, Host screen and Viewer screen
HlsViewModel owns the Room and exposes the room ID, role, participants, HLS state, and playback URL as Compose state. MainActivity uses that state to show the join, host, or viewer screen. The viewer opens the player only after VideoSDK reports that the stream is playable.
Step 1: Initialize VideoSDK
Create MainApplication, which extends android.app.Application. It holds the token and initializes the SDK once for the whole app.
package live.videosdk.rtc.android.hlsquickstart
import android.app.Application
import live.videosdk.rtc.android.VideoSDK
import live.videosdk.rtc.android.lib.tracing.LogLevel
class MainApplication : Application() {
// For this quick start only. Fetch a short-lived token from your server in production.
val sampleToken = "YOUR_TOKEN"
override fun onCreate() {
super.onCreate()
// DEBUG logs help while you build. Use a less verbose level in production.
VideoSDK.setLogLevel(LogLevel.DEBUG)
VideoSDK.initialize(applicationContext)
}
}
Register it in the manifest as shown in Configure the manifest.
Step 2: Create or join a room and control HLS
HlsViewModel creates or joins a room, selects the correct participant mode, tracks participants, and controls HLS.
Three details matter here.
The participant mode controls media usage. A host joins as Mode.SEND_AND_RECV to publish camera and microphone. A viewer joins as Mode.SIGNALLING_ONLY because playback comes from HLS.
Wait for the playable state. HLS_STARTED means the server accepted the request, but the stream is not ready for playback. Start the player only when onHlsStateChanged reports HLSState.HLS_PLAYABLE and provides hlsUrl.playbackHlsUrl. This usually takes several seconds.
Call the SDK from a coroutine. join(), leave(), startHLS(), stopHLS() and the media toggles are suspend functions. The view model calls them from viewModelScope and shows any VideoSDKException they throw.
package live.videosdk.rtc.android.hlsquickstart
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import live.videosdk.rtc.android.Participant
import live.videosdk.rtc.android.Room
import live.videosdk.rtc.android.VideoSDK
import live.videosdk.rtc.android.lib.HLSState
import live.videosdk.rtc.android.lib.LeaveReason
import live.videosdk.rtc.android.lib.Mode
import live.videosdk.rtc.android.lib.VideoSDKError
import live.videosdk.rtc.android.lib.VideoSDKException
import live.videosdk.rtc.android.listeners.RoomEventListener
import live.videosdk.rtc.android.model.ConfigLayout
import live.videosdk.rtc.android.model.ConfigLayoutGrid
import live.videosdk.rtc.android.model.ConfigLayoutPriority
import live.videosdk.rtc.android.model.ConfigOrientation
import live.videosdk.rtc.android.model.ConfigQuality
import live.videosdk.rtc.android.model.ConfigTheme
import live.videosdk.rtc.android.model.HLSConfig
import live.videosdk.rtc.android.model.HLSUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/** Host publishes video and controls the stream; Viewer only watches the HLS feed. */
enum class Role { HOST, VIEWER }
class HlsViewModel(application: Application) : AndroidViewModel(application) {
private val token = (application as? MainApplication)?.sampleToken
?: error("Add android:name=\".MainApplication\" to <application> in AndroidManifest.xml")
private var room: Room? = null
// Non-null while we are inside a room. MainActivity switches screens on it.
var roomId by mutableStateOf<String?>(null)
private set
var role by mutableStateOf(Role.HOST)
private set
val participants = mutableStateListOf<Participant>()
var micEnabled by mutableStateOf(true)
private set
var camEnabled by mutableStateOf(true)
private set
// Server-reported stream state, shown on the host screen. HLS_STOPPED until HLS is first started.
var hlsState by mutableStateOf(HLSState.HLS_STOPPED)
private set
// Set once the server has a stream the viewer can actually play.
var playbackUrl by mutableStateOf<String?>(null)
private set
// Set by the SDK (or a failed network call); MainActivity shows it as a Toast.
var errorMessage by mutableStateOf<String?>(null)
val isLive: Boolean
get() = hlsState == HLSState.HLS_STARTED || hlsState == HLSState.HLS_PLAYABLE
val isHlsTransitioning: Boolean
get() = hlsState == HLSState.HLS_STARTING || hlsState == HLSState.HLS_STOPPING
// Creates a room through the VideoSDK REST API, then joins it as host.
fun createAndJoinRoom() {
if (room != null) return // already joining or joined
viewModelScope.launch {
try {
val roomId = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("https://api.videosdk.live/v2/rooms")
.header("Authorization", token)
.post("".toRequestBody())
.build()
OkHttpClient().newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
check(response.isSuccessful) { "HTTP ${response.code}: $body" }
JSONObject(body).getString("roomId")
}
}
joinRoom(roomId, Role.HOST)
} catch (e: Exception) {
errorMessage = "Could not create room: ${e.message}"
}
}
}
// Creates the Room object and joins it.
fun joinRoom(roomId: String, role: Role) {
if (room != null) return // already joining or joined
val host = role == Role.HOST
val room = try {
VideoSDK.createRoom(
roomId = roomId,
token = token,
participantName = if (host) "Host" else "Viewer",
micEnabled = host,
camEnabled = host,
// A viewer watches the HLS feed.
mode = if (host) Mode.SEND_AND_RECV else Mode.SIGNALLING_ONLY,
multiStream = false,
preferredProtocol = VideoSDK.PreferredProtocol.UDP_OVER_TCP, // the default
)
} catch (e: VideoSDKException) {
errorMessage = e.message // an empty token or room id
return
}
room.addEventListener(roomEventListener)
// Set these first: onRoomJoined can arrive before join() returns.
this.role = role
this.room = room
this.roomId = roomId
this.micEnabled = host
this.camEnabled = host
viewModelScope.launch {
try {
room.join()
syncMediaState(room)
} catch (e: VideoSDKException) {
// For example an invalid token. Skipped if the user already left.
if (this@HlsViewModel.room === room) {
room.removeAllEventListeners()
reset()
errorMessage = "Could not join: ${e.message}"
}
}
}
}
private val roomEventListener = object : RoomEventListener() {
override fun onRoomJoined(switchRoomId: String?) {
room?.let { if (role == Role.HOST) addParticipant(it.localParticipant) }
}
override fun onParticipantJoined(participant: Participant) {
if (role == Role.HOST) addParticipant(participant)
}
override fun onParticipantLeft(participant: Participant, reason: LeaveReason) {
participants.removeAll { it.id == participant.id }
}
// Carries the playback address once the stream is playable.
override fun onHlsStateChanged(state: HLSState, hlsUrl: HLSUrl?) {
hlsState = state
// The server needs a few seconds after HLS_STARTED before a stream is playable.
playbackUrl = if (state == HLSState.HLS_PLAYABLE) {
hlsUrl?.playbackHlsUrl?.ifBlank { null }
} else {
null
}
}
// Also fired by the SDK when the server ends the call.
override fun onRoomLeft(reason: LeaveReason) {
reset()
}
// onError can arrive off the main thread, so switch to it before updating state.
override fun onError(error: VideoSDKError) {
viewModelScope.launch { errorMessage = error.message }
}
}
// onRoomJoined fires again after a reconnect; never list the same participant twice.
private fun addParticipant(participant: Participant) {
if (participants.none { it.id == participant.id }) participants.add(participant)
}
fun toggleMic() {
val room = room ?: return
viewModelScope.launch {
try {
if (room.isMicEnabled) room.muteMic() else room.unmuteMic()
} catch (e: VideoSDKException) {
errorMessage = e.message
}
syncMediaState(room)
}
}
fun toggleCam() {
val room = room ?: return
viewModelScope.launch {
try {
if (room.isCamEnabled) room.disableCam() else room.enableCam()
} catch (e: VideoSDKException) {
errorMessage = e.message
}
syncMediaState(room)
}
}
// Shows what the SDK actually did, for example a camera that could not start.
// Skipped once that room is gone, so the next room starts from the defaults.
private fun syncMediaState(room: Room) {
if (room !== this.room) return
micEnabled = room.isMicEnabled
camEnabled = room.isCamEnabled
}
fun toggleHls() {
val room = room ?: return
viewModelScope.launch {
try {
if (isLive) {
room.stopHLS()
} else {
// Layout, theme and quality of the composed stream the server produces.
val config = HLSConfig(
layout = ConfigLayout(
type = ConfigLayoutGrid.SPOTLIGHT,
priority = ConfigLayoutPriority.PIN,
gridSize = 4,
),
orientation = ConfigOrientation.PORTRAIT,
theme = ConfigTheme.DARK,
quality = ConfigQuality.HIGH,
)
room.startHLS(config)
}
} catch (e: VideoSDKException) {
errorMessage = e.message
}
}
}
fun leaveRoom() {
val room = room ?: return
room.removeAllEventListeners() // this Room is finished, ignore anything else it reports
viewModelScope.launch {
// Finish leaving even if the ViewModel is cleared in the meantime.
withContext(NonCancellable) {
try {
room.leave()
} catch (e: VideoSDKException) {
// The join never completed, so there is nothing to leave.
}
}
}
// Reset here instead of waiting for a callback: onRoomLeft never fires if the join itself failed.
reset()
}
// Back to the Join screen with clean state; setting roomId to null switches the UI.
private fun reset() {
room = null
participants.clear()
micEnabled = true
camEnabled = true
hlsState = HLSState.HLS_STOPPED
playbackUrl = null
roomId = null
}
}
Step 3: Render the host's participants
The host screen shows each participant's camera. Because VideoView is an Android View, use AndroidView to render it inside Compose.
- Learn more about the
VideoViewcomponent. - Key each tile by participant ID so Compose does not reuse a video renderer for a different participant.
- Call
releaseSurfaceViewRenderer()when a tile leaves the composition. It also detaches the track. addEventListenercallsonStreamEnabledright away for the streams the participant already has, so a camera that is already on shows as soon as the tile appears.
package live.videosdk.rtc.android.hlsquickstart
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import live.videosdk.rtc.android.Participant
import live.videosdk.rtc.android.RTCStream
import live.videosdk.rtc.android.MediaType
import live.videosdk.rtc.android.VideoView
import live.videosdk.rtc.android.listeners.ParticipantEventListener
import videosdk.org.webrtc.VideoTrack
@Composable
fun ParticipantsGrid(participants: List<Participant>, modifier: Modifier = Modifier) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.padding(8.dp)
) {
// Keyed by participant id so a tile is never reused for someone else.
items(participants, key = { it.id }) { participant ->
ParticipantVideoView(participant)
}
}
}
@Composable
fun ParticipantVideoView(participant: Participant) {
var videoTrack by remember(participant) { mutableStateOf<VideoTrack?>(null) }
// One listener per tile: follows the participant's camera on/off. Adding it also
// replays the streams the participant already has, so a camera that is on shows at once.
DisposableEffect(participant) {
val listener = object : ParticipantEventListener() {
override fun onStreamEnabled(stream: RTCStream) {
if (stream.kind == MediaType.VIDEO) videoTrack = stream.track as? VideoTrack
}
override fun onStreamDisabled(stream: RTCStream) {
if (stream.kind == MediaType.VIDEO) videoTrack = null
}
}
participant.addEventListener(listener)
onDispose { participant.removeEventListener(listener) }
}
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(3f / 4f)
.background(Color.DarkGray)
) {
// VideoView is a classic View, so it is wrapped in AndroidView.
AndroidView(
factory = { context -> VideoView(context) },
update = { view ->
// Re-attach only when the track actually changed.
if (view.tag != videoTrack) {
view.removeTrack()
videoTrack?.let { view.addTrack(it) }
view.tag = videoTrack
}
},
// Detaches the track and frees the renderer.
onRelease = { view -> view.releaseSurfaceViewRenderer() },
modifier = Modifier.fillMaxSize()
)
if (videoTrack == null) {
Text(
text = "Camera off",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
Text(
text = participant.displayName,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color(0x99000000))
.padding(4.dp)
)
}
}
Step 4: Play the stream for the viewer
The viewer plays the HLS URL with Media3 ExoPlayer.
This example connects ExoPlayer to a TextureView, which renders reliably inside the Compose hierarchy.
A player holds a hardware decoder and a network connection. Release it when the screen goes away, or the app keeps decoding in the background.
package live.videosdk.rtc.android.hlsquickstart
import android.view.TextureView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
/** Plays the HLS stream at [url]. A new url builds a new player; leaving the screen releases it. */
@Composable
fun HlsPlayerView(url: String, modifier: Modifier = Modifier) {
val context = LocalContext.current
val player = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
playWhenReady = true
}
}
// A player holds a codec and a socket, so it must be released when this leaves the screen.
DisposableEffect(player) {
onDispose { player.release() }
}
AndroidView(
// TextureView keeps video rendering inside the Compose window hierarchy.
factory = { TextureView(it) },
update = { player.setVideoTextureView(it) },
onRelease = { player.clearVideoSurface() },
modifier = modifier
)
}
Step 5: Build the screens
MainActivity displays one of three screens based on the view model state.
- Join screen creates a room or joins an existing room as a host or viewer. It requests camera and microphone permissions only for a host action.
- Host screen shows the participant grid, the current stream state, and controls for HLS, the microphone, the camera, and leaving the room.
- Viewer screen shows a waiting message until a playable URL arrives, then opens the player.
package live.videosdk.rtc.android.hlsquickstart
import android.Manifest
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import live.videosdk.rtc.android.lib.HLSState
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
VideoSDKHlsApp()
}
}
}
}
@Composable
fun VideoSDKHlsApp(viewModel: HlsViewModel = viewModel()) {
val context = LocalContext.current
// Show SDK and network errors as a Toast.
viewModel.errorMessage?.let { message ->
LaunchedEffect(message) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
viewModel.errorMessage = null
}
}
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val roomId = viewModel.roomId
val modifier = Modifier.padding(innerPadding)
when {
roomId == null -> JoinScreen(viewModel, modifier)
viewModel.role == Role.HOST -> HostScreen(roomId, viewModel, modifier)
else -> ViewerScreen(roomId, viewModel, modifier)
}
}
}
@Composable
fun JoinScreen(viewModel: HlsViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
var input by rememberSaveable { mutableStateOf("") }
var pendingHostAction by remember { mutableStateOf<(() -> Unit)?>(null) }
// A viewer only plays HLS, so request media permissions for hosts only.
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val mediaPermissionDenied = result.values.any { granted -> !granted }
if (mediaPermissionDenied) {
Toast.makeText(
context,
"Allow camera and microphone access to join as a host.",
Toast.LENGTH_LONG
).show()
} else {
pendingHostAction?.invoke()
}
pendingHostAction = null
}
fun runAsHost(action: () -> Unit) {
pendingHostAction = action
permissionLauncher.launch(
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
)
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(onClick = { runAsHost(viewModel::createAndJoinRoom) }) {
Text("Create Room")
}
Text("OR", modifier = Modifier.padding(16.dp))
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text("Enter Room ID") },
singleLine = true
)
Button(
onClick = {
if (input.isNotBlank()) {
runAsHost { viewModel.joinRoom(input.trim(), Role.HOST) }
}
},
modifier = Modifier.padding(top = 8.dp)
) {
Text("Join as Host")
}
Button(
onClick = { if (input.isNotBlank()) viewModel.joinRoom(input.trim(), Role.VIEWER) },
modifier = Modifier.padding(top = 8.dp)
) {
Text("Join as Viewer")
}
}
}
@Composable
fun HostScreen(roomId: String, viewModel: HlsViewModel, modifier: Modifier = Modifier) {
// Back button leaves the room and brings the Join screen back.
BackHandler { viewModel.leaveRoom() }
Column(modifier = modifier.fillMaxSize()) {
Text(
text = "Room ID: $roomId",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
Text(
text = "HLS state: ${viewModel.hlsState.name}",
modifier = Modifier.padding(horizontal = 16.dp)
)
ParticipantsGrid(viewModel.participants, Modifier.weight(1f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = { viewModel.toggleHls() },
enabled = !viewModel.isHlsTransitioning
) {
Text(
when (viewModel.hlsState) {
HLSState.HLS_STARTING -> "Starting..."
HLSState.HLS_STOPPING -> "Stopping..."
else -> if (viewModel.isLive) "Stop HLS" else "Start HLS"
}
)
}
Button(onClick = { viewModel.toggleMic() }) {
Text(if (viewModel.micEnabled) "Mute Mic" else "Unmute Mic")
}
Button(onClick = { viewModel.toggleCam() }) {
Text(if (viewModel.camEnabled) "Cam Off" else "Cam On")
}
Button(onClick = { viewModel.leaveRoom() }) {
Text("Leave")
}
}
}
}
@Composable
fun ViewerScreen(roomId: String, viewModel: HlsViewModel, modifier: Modifier = Modifier) {
BackHandler { viewModel.leaveRoom() }
Column(modifier = modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Room ID: $roomId",
style = MaterialTheme.typography.titleMedium
)
Button(onClick = { viewModel.leaveRoom() }) {
Text("Leave")
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color.Black),
contentAlignment = Alignment.Center
) {
val url = viewModel.playbackUrl
if (url == null) {
Text(
text = "Waiting for the host\nto start the live stream",
color = Color.White,
textAlign = TextAlign.Center
)
} else {
HlsPlayerView(url, Modifier.fillMaxSize())
}
}
}
}
Run the app
- Replace
YOUR_TOKENinMainApplication.ktwith your temporary dashboard token. - Run the app on two devices.
- On the first device, tap Create Room and grant camera and microphone access. The device joins as the host and displays the room ID.
- On the second device, enter the same room ID and tap Join as Viewer. The viewer does not need camera or microphone access.
- On the host, tap Start HLS. The state changes from
HLS_STARTINGtoHLS_STARTED, then toHLS_PLAYABLE. Playback begins automatically when the stream is playable. This can take several seconds while the server composes and publishes the stream. - Tap Stop HLS on the host. The viewer returns to the waiting message.
On an emulator the software decoder can render the stream with colored banding. That is the emulator, not your code. Use a physical device to judge video quality.
Expected result
You now have a Jetpack Compose HLS app in which a host can publish and broadcast a room while a viewer watches the stream with ExoPlayer.
Next steps
Got a Question? Ask us on discord

