HLS Quick Start for Android with Jetpack Compose
Build an Android app that broadcasts a VideoSDK meeting 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 meeting, publishes camera and microphone, and starts or stops HLS.
- A viewer who joins with the meeting 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 21 or later. This quick start uses
minSdk = 24. - A VideoSDK account and a temporary token from the VideoSDK dashboard.
- Two Android devices or emulators: one for the host and one for the viewer.
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. You can also browse the Android HLS sample projects.
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 for the meeting 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 = "1.3.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 meeting 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:1.3.0"
// Creates a meeting 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 supports armeabi-v7a, arm64-v8a, and x86_64. For an emulator, choose an x86_64 image on an Intel computer or an arm64-v8a image on Apple silicon.
Configure the manifest
In app/src/main/AndroidManifest.xml, add the permissions and register the MainApplication class that you create in Step 1.
<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 meeting 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 Meeting and exposes the meeting 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 meeting and control HLS
HlsViewModel creates or joins a meeting, selects the correct participant mode, tracks participants, and controls HLS.
Two details matter here.
The participant mode controls media usage. A host joins as SEND_AND_RECV to publish camera and microphone. A viewer joins as 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 HLS_PLAYABLE and provides playbackHlsUrl. This usually takes several seconds.
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.launch
import kotlinx.coroutines.withContext
import live.videosdk.rtc.android.Meeting
import live.videosdk.rtc.android.Participant
import live.videosdk.rtc.android.VideoSDK
import live.videosdk.rtc.android.listeners.MeetingEventListener
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 meeting: Meeting? = null
// Non-null while we are inside a meeting. MainActivity switches screens on it.
var meetingId 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 webcamEnabled by mutableStateOf(true)
private set
// Server-reported stream state, shown on the host screen. Drives the Start/Stop button label.
var hlsState by mutableStateOf("NOT_STARTED")
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 == "HLS_STARTED" || hlsState == "HLS_PLAYABLE"
val isHlsTransitioning: Boolean
get() = hlsState == "HLS_STARTING" || hlsState == "HLS_STOPPING"
// Creates a room through the VideoSDK REST API, then joins it as host.
fun createMeeting() {
if (meeting != 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")
}
}
joinMeeting(roomId, Role.HOST)
} catch (e: Exception) {
errorMessage = "Could not create meeting: ${e.message}"
}
}
}
// Configures the SDK, creates the Meeting object and joins it.
fun joinMeeting(meetingId: String, role: Role) {
if (meeting != null) return // already joining or joined
this.role = role
val host = role == Role.HOST
VideoSDK.config(token)
val meeting = VideoSDK.initMeeting(
getApplication<Application>(), // context
meetingId, // meetingId
if (host) "Host" else "Viewer", // participant name
host, // micEnabled
host, // webcamEnabled
null, // participantId, SDK generates one
// A viewer watches the HLS feed.
if (host) "SEND_AND_RECV" else "SIGNALLING_ONLY",
false, // multiStream
null, // customTracks
null, // metaData
VideoSDK.PreferredProtocol.UDP_OVER_TCP // preferredProtocol (the default)
)
meeting.addEventListener(meetingEventListener)
meeting.join()
this.meeting = meeting
this.meetingId = meetingId
this.micEnabled = host
this.webcamEnabled = host
}
// All SDK callbacks arrive on the main thread.
private val meetingEventListener = object : MeetingEventListener() {
override fun onMeetingJoined() {
meeting?.let { if (role == Role.HOST) addParticipant(it.localParticipant) }
}
override fun onParticipantJoined(participant: Participant) {
if (role == Role.HOST) addParticipant(participant)
}
override fun onParticipantLeft(participant: Participant) {
participants.removeAll { it.id == participant.id }
}
// The only HLS callback that carries the playback address; onHlsStarted does not.
override fun onHlsStateChanged(state: JSONObject) {
hlsState = state.optString("status")
// The server needs a few seconds after HLS_STARTED before a stream is playable.
playbackUrl = if (hlsState == "HLS_PLAYABLE") {
state.optString("playbackHlsUrl").ifBlank { null }
} else {
null
}
}
// Also fired by the SDK when the server ends the call.
override fun onMeetingLeft() {
reset()
}
override fun onError(error: JSONObject) {
errorMessage = error.optString("message")
}
}
// The SDK replays join callbacks 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() {
if (micEnabled) meeting?.muteMic() else meeting?.unmuteMic()
micEnabled = !micEnabled
}
fun toggleWebcam() {
if (webcamEnabled) meeting?.disableWebcam() else meeting?.enableWebcam()
webcamEnabled = !webcamEnabled
}
fun toggleHls() {
if (isLive) {
meeting?.stopHls()
} else {
// Layout, theme and quality of the composed stream the server produces.
val layout = JSONObject()
.put("type", "SPOTLIGHT")
.put("priority", "PIN")
.put("gridSize", 4)
val config = JSONObject()
.put("layout", layout)
.put("orientation", "portrait")
.put("theme", "DARK")
.put("quality", "high")
meeting?.startHls(config, null)
}
}
fun leaveMeeting() {
meeting?.let {
it.removeAllListeners() // this Meeting is finished, ignore anything else it reports
it.leave()
}
// Reset here instead of waiting for a callback: onMeetingLeft never fires if the join itself failed.
reset()
}
// Back to the Join screen with clean state; setting meetingId to null switches the UI.
private fun reset() {
meeting = null
participants.clear()
micEnabled = true
webcamEnabled = true
hlsState = "NOT_STARTED"
playbackUrl = null
meetingId = 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
removeTrack()beforereleaseSurfaceViewRenderer()when a tile leaves the composition.
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.Stream
import live.videosdk.rtc.android.VideoView
import live.videosdk.rtc.android.listeners.ParticipantEventListener
import 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(participant.videoTrack()) }
// One listener per tile: follows the participant's camera on/off.
DisposableEffect(participant) {
val listener = object : ParticipantEventListener() {
override fun onStreamEnabled(stream: Stream) {
if (stream.kind == "video") videoTrack = stream.track as VideoTrack
}
override fun onStreamDisabled(stream: Stream) {
if (stream.kind == "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
}
},
onRelease = { view ->
view.removeTrack() // must come first, release is a no-op while a track is attached
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)
)
}
}
// The participant's current camera track, if the camera is on.
private fun Participant.videoTrack(): VideoTrack? =
streams.values.firstOrNull { it.kind == "video" }?.track as? VideoTrack
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 meeting or joins an existing meeting 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 meeting.
- 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
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 meetingId = viewModel.meetingId
val modifier = Modifier.padding(innerPadding)
when {
meetingId == null -> JoinScreen(viewModel, modifier)
viewModel.role == Role.HOST -> HostScreen(meetingId, viewModel, modifier)
else -> ViewerScreen(meetingId, 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 receives signalling and 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::createMeeting) }) {
Text("Create Meeting")
}
Text("OR", modifier = Modifier.padding(16.dp))
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text("Enter Meeting ID") },
singleLine = true
)
Button(
onClick = {
if (input.isNotBlank()) {
runAsHost { viewModel.joinMeeting(input.trim(), Role.HOST) }
}
},
modifier = Modifier.padding(top = 8.dp)
) {
Text("Join as Host")
}
Button(
onClick = { if (input.isNotBlank()) viewModel.joinMeeting(input.trim(), Role.VIEWER) },
modifier = Modifier.padding(top = 8.dp)
) {
Text("Join as Viewer")
}
}
}
@Composable
fun HostScreen(meetingId: String, viewModel: HlsViewModel, modifier: Modifier = Modifier) {
// Back button leaves the meeting; onMeetingLeft brings the join screen back.
BackHandler { viewModel.leaveMeeting() }
Column(modifier = modifier.fillMaxSize()) {
Text(
text = "Meeting ID: $meetingId",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
Text(
text = "HLS state: ${viewModel.hlsState}",
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) {
"HLS_STARTING" -> "Starting..."
"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.toggleWebcam() }) {
Text(if (viewModel.webcamEnabled) "Cam Off" else "Cam On")
}
Button(onClick = { viewModel.leaveMeeting() }) {
Text("Leave")
}
}
}
}
@Composable
fun ViewerScreen(meetingId: String, viewModel: HlsViewModel, modifier: Modifier = Modifier) {
BackHandler { viewModel.leaveMeeting() }
Column(modifier = modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Meeting ID: $meetingId",
style = MaterialTheme.typography.titleMedium
)
Button(onClick = { viewModel.leaveMeeting() }) {
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 Meeting and grant camera and microphone access. The device joins as the host and displays the meeting ID.
- On the second device, enter the same meeting 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 meeting while a viewer watches the stream with ExoPlayer.
Browse the Android HLS samples for complete Kotlin and Java projects.
Next steps
Got a Question? Ask us on discord

