Skip to content

Instantly share code, notes, and snippets.

@mxvsh
Created August 15, 2025 05:28
Show Gist options
  • Select an option

  • Save mxvsh/e58037832d241cbe7afb38cbd4e3bdaa to your computer and use it in GitHub Desktop.

Select an option

Save mxvsh/e58037832d241cbe7afb38cbd4e3bdaa to your computer and use it in GitHub Desktop.
Android TV WebRTC Demo
dependencies {
implementation("io.getstream:stream-webrtc-android:1.3.8")}
}
package com.sideorg.jamm
import android.annotation.SuppressLint
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import android.view.KeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.tv.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import fi.iki.elonen.NanoHTTPD
import org.webrtc.*
import java.nio.charset.Charset
import java.util.concurrent.ConcurrentHashMap
// Minimal app state holder
class ClientSession(
val id: String,
val name: String,
val pc: PeerConnection,
val renderer: SurfaceViewRenderer
)
class WebRtcManager(private val activity: ComponentActivity) {
private val TAG = "WebRtcManager"
private val eglBase: EglBase = EglBase.create()
private val pcFactory: PeerConnectionFactory
val sessions = ConcurrentHashMap<String, ClientSession>()
init {
val initializationOptions = PeerConnectionFactory.InitializationOptions.builder(activity.applicationContext)
.setEnableInternalTracer(false)
.createInitializationOptions()
PeerConnectionFactory.initialize(initializationOptions)
val encoderFactory = DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)
val decoderFactory = DefaultVideoDecoderFactory(eglBase.eglBaseContext)
pcFactory = PeerConnectionFactory.builder()
.setVideoEncoderFactory(encoderFactory)
.setVideoDecoderFactory(decoderFactory)
.createPeerConnectionFactory()
}
fun createRenderer(context: android.content.Context): SurfaceViewRenderer {
return SurfaceViewRenderer(context).apply {
init(eglBase.eglBaseContext, null)
setEnableHardwareScaler(true)
setMirror(false)
}
}
private fun rtcConfig(): PeerConnection.RTCConfiguration {
return PeerConnection.RTCConfiguration(
listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer()
)
).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
}
}
suspend fun handleOffer(sessionId: String, clientName: String, sdpOffer: String, renderer: SurfaceViewRenderer): String = withContext(Dispatchers.Main) {
val pc = pcFactory.createPeerConnection(rtcConfig(), object : PeerConnection.Observer {
override fun onSignalingChange(newState: PeerConnection.SignalingState) {}
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState) {
Log.d(TAG, "ICE state $sessionId -> $newState")
if (newState == PeerConnection.IceConnectionState.DISCONNECTED || newState == PeerConnection.IceConnectionState.FAILED || newState == PeerConnection.IceConnectionState.CLOSED) {
activity.runOnUiThread { removeSession(sessionId) }
}
}
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState) {}
override fun onIceCandidate(candidate: IceCandidate) {}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {}
override fun onAddStream(stream: MediaStream) {}
override fun onRemoveStream(stream: MediaStream) {}
override fun onDataChannel(dc: DataChannel) {}
override fun onRenegotiationNeeded() {}
override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) {
val track = receiver.track()
if (track is VideoTrack) {
track.addSink(renderer)
}
}
}) ?: throw IllegalStateException("Failed to create PeerConnection")
val audioSource = pcFactory.createAudioSource(MediaConstraints())
val audioTrack = pcFactory.createAudioTrack("ARDAMSa0", audioSource)
pc.addTrack(audioTrack)
val offer = SessionDescription(SessionDescription.Type.OFFER, sdpOffer)
pc.setRemoteDescription(SimpleSdpObserver(), offer)
val constraints = MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
var localDesc: SessionDescription? = null
pc.createAnswer(object : SimpleSdpObserver() {
override fun onCreateSuccess(desc: SessionDescription) {
pc.setLocalDescription(SimpleSdpObserver(), desc)
localDesc = desc
}
}, constraints)
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
while (localDesc == null && System.currentTimeMillis() - start < 3000) {
Thread.sleep(50)
}
Thread.sleep(500)
}
sessions[sessionId] = ClientSession(sessionId, clientName, pc, renderer)
return@withContext (pc.localDescription?.description ?: localDesc?.description ?: "")
}
fun removeSession(sessionId: String) {
sessions.remove(sessionId)?.let { s ->
s.pc.close()
s.renderer.release()
}
}
fun disposeAll() {
sessions.keys.toList().forEach { removeSession(it) }
pcFactory.dispose()
eglBase.release()
}
}
open class SimpleSdpObserver : SdpObserver {
override fun onCreateSuccess(sessionDescription: SessionDescription) {}
override fun onSetSuccess() {}
override fun onCreateFailure(s: String) { Log.e("SDP", "onCreateFailure: $s") }
override fun onSetFailure(s: String) { Log.e("SDP", "onSetFailure: $s") }
}
class SignalingHttpServer(
private val manager: WebRtcManager,
private val activity: ComponentActivity,
port: Int = 8080
) : NanoHTTPD(port) {
private val indexHtml = """
<!DOCTYPE html>
<html>
<head>
<title>TV Cast</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.container { text-align: center; }
input { padding: 10px; margin: 10px; font-size: 16px; width: 200px; }
button { padding: 12px 24px; font-size: 16px; margin: 10px; cursor: pointer; }
.status { margin: 20px 0; padding: 10px; background: #f0f0f0; border-radius: 4px; }
.error { background: #ffe6e6; color: #cc0000; }
.success { background: #e6ffe6; color: #006600; }
</style>
</head>
<body>
<div class="container">
<h1>Cast to TV</h1>
<div id="setup">
<input type="text" id="nameInput" placeholder="Enter your name" />
<br/>
<button onclick="startCasting()">Start Screen Cast</button>
</div>
<div id="status" class="status" style="display:none;"></div>
</div>
<script>
let pc = null;
let localStream = null;
function showStatus(message, isError = false) {
const status = document.getElementById('status');
status.textContent = message;
status.className = 'status ' + (isError ? 'error' : 'success');
status.style.display = 'block';
}
async function startCasting() {
const name = document.getElementById('nameInput').value.trim();
if (!name) {
alert('Please enter your name');
return;
}
try {
showStatus('Getting screen permission...');
// Get screen capture
localStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
});
showStatus('Connecting to TV...');
// Create peer connection
pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
});
// Add local stream
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Send offer to server
const sessionId = Math.random().toString(36).substring(2);
const response = await fetch('/webrtc?name=' + encodeURIComponent(name) + '&sid=' + sessionId, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
});
if (!response.ok) {
throw new Error('Failed to connect to TV');
}
const answerSdp = await response.text();
await pc.setRemoteDescription(new RTCSessionDescription({
type: 'answer',
sdp: answerSdp
}));
showStatus('Connected! Your screen is now casting to the TV.');
// Handle stream end
localStream.getVideoTracks()[0].addEventListener('ended', () => {
showStatus('Screen sharing stopped.');
cleanup();
});
} catch (error) {
console.error('Error:', error);
showStatus('Failed to start casting: ' + error.message, true);
cleanup();
}
}
function cleanup() {
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
if (pc) {
pc.close();
pc = null;
}
}
// Cleanup on page unload
window.addEventListener('beforeunload', cleanup);
</script>
</body>
</html>
"""
override fun serve(session: IHTTPSession): Response {
Log.d("HTTP", "Request: ${session.method} ${session.uri}")
return when {
session.uri == "/" -> newFixedLengthResponse(Response.Status.OK, "text/html", indexHtml)
session.uri == "/webrtc" && session.method == Method.POST -> handleWebRtc(session)
session.uri == "/webrtc" && session.method == Method.OPTIONS -> {
newFixedLengthResponse(Response.Status.OK, "text/plain", "").apply {
addHeader("Access-Control-Allow-Origin", "*")
addHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
addHeader("Access-Control-Allow-Headers", "Content-Type")
}
}
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
}
}
private fun handleWebRtc(session: IHTTPSession): Response {
return try {
val name = session.parameters["name"]?.firstOrNull() ?: "Guest"
val sid = session.parameters["sid"]?.firstOrNull() ?: java.util.UUID.randomUUID().toString()
// Read the SDP offer from the request body
val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0
val buffer = ByteArray(contentLength)
var totalBytesRead = 0
while (totalBytesRead < contentLength) {
val bytesRead = session.inputStream.read(buffer, totalBytesRead, contentLength - totalBytesRead)
if (bytesRead == -1) break
totalBytesRead += bytesRead
}
val sdpOffer = String(buffer, 0, totalBytesRead, Charset.forName("UTF-8"))
if (sdpOffer.isEmpty()) {
Log.e("WebRTC", "Empty SDP offer received")
return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Empty SDP offer")
}
Log.d("WebRTC", "Received offer from $name (sid: $sid), SDP length: ${sdpOffer.length}")
// Create renderer on main thread
val renderer = runBlocking(Dispatchers.Main) {
manager.createRenderer(activity)
}
val answerSdp = runBlocking(Dispatchers.Main) {
manager.handleOffer(sid, name, sdpOffer, renderer)
}
Log.d("WebRTC", "Generated answer SDP length: ${answerSdp.length}")
newFixedLengthResponse(Response.Status.OK, "application/sdp", answerSdp).apply {
addHeader("Access-Control-Allow-Origin", "*")
addHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
addHeader("Access-Control-Allow-Headers", "Content-Type")
}
} catch (e: Exception) {
Log.e("WebRTC", "Error handling WebRTC request", e)
newFixedLengthResponse(Response.Status.INTERNAL_ERROR, "text/plain", "Server error: ${e.message}")
}
}
}
class MainViewModel : androidx.lifecycle.ViewModel() {
private val _clients = MutableStateFlow<List<ClientSession>>(emptyList())
val clients = _clients.asStateFlow()
private val _selectedIndex = MutableStateFlow(0)
val selectedIndex = _selectedIndex.asStateFlow()
fun bind(manager: WebRtcManager) {
viewModelScope.launch(Dispatchers.Default) {
while (isActive) {
val clientsList = manager.sessions.values.sortedBy { it.name }
_clients.value = clientsList
// Adjust selected index if needed
if (_selectedIndex.value >= clientsList.size && clientsList.isNotEmpty()) {
_selectedIndex.value = clientsList.size - 1
} else if (clientsList.isEmpty()) {
_selectedIndex.value = 0
}
delay(250)
}
}
}
fun navigateLeft() {
val currentClients = _clients.value
if (currentClients.isNotEmpty() && _selectedIndex.value > 0) {
_selectedIndex.value = _selectedIndex.value - 1
}
}
fun navigateRight() {
val currentClients = _clients.value
if (currentClients.isNotEmpty() && _selectedIndex.value < currentClients.size - 1) {
_selectedIndex.value = _selectedIndex.value + 1
}
}
}
class MainActivity : ComponentActivity() {
private lateinit var webRtc: WebRtcManager
private lateinit var server: SignalingHttpServer
private val vm: MainViewModel by viewModels()
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webRtc = WebRtcManager(this)
server = SignalingHttpServer(webRtc, this, 8080)
server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
vm.bind(webRtc)
setContent {
MaterialTheme {
val clients by vm.clients.collectAsState(initial = emptyList())
val selectedIndex by vm.selectedIndex.collectAsState()
Box(Modifier.fillMaxSize().background(androidx.compose.ui.graphics.Color(0xFF101010))) {
if (clients.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("TV Cast Receiver", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = androidx.compose.ui.graphics.Color.White)
Spacer(Modifier.height(24.dp))
Text("No clients connected", fontSize = 18.sp, color = androidx.compose.ui.graphics.Color.Gray)
Spacer(Modifier.height(16.dp))
Text("Visit http://[YOUR_IP]:8080 to start casting", fontSize = 14.sp, color = androidx.compose.ui.graphics.Color.Gray)
}
}
} else {
// Show selected client in full screen
val selectedClient = clients.getOrNull(selectedIndex)
if (selectedClient != null) {
Box(Modifier.fillMaxSize()) {
// Use key to force recreation of AndroidView when client changes
key(selectedClient.id) {
AndroidView(
factory = { context ->
selectedClient.renderer
},
modifier = Modifier.fillMaxSize()
)
}
// Overlay with client info and navigation hints
Column(
Modifier
.align(Alignment.TopStart)
.padding(24.dp)
.background(
androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.7f),
androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
)
.padding(16.dp)
) {
Text(
selectedClient.name,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color.White
)
Text(
"Client ${selectedIndex + 1} of ${clients.size}",
fontSize = 14.sp,
color = androidx.compose.ui.graphics.Color.Gray
)
if (clients.size > 1) {
Text(
"Use ← → to switch clients",
fontSize = 12.sp,
color = androidx.compose.ui.graphics.Color.Gray
)
}
}
}
}
}
}
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
vm.navigateLeft()
true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
vm.navigateRight()
true
}
else -> super.onKeyDown(keyCode, event)
}
}
@Composable
private fun ClientTile(session: ClientSession) {
Card(
onClick = {},
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.padding(12.dp)) {
Text(session.name, color = androidx.compose.ui.graphics.Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
AndroidView(
factory = { session.renderer },
modifier = Modifier.fillMaxWidth().height(220.dp)
)
Spacer(Modifier.height(6.dp))
Text("ID: ${session.id}", color = androidx.compose.ui.graphics.Color.Gray, fontSize = 12.sp)
}
}
}
override fun onDestroy() {
super.onDestroy()
server.stop()
webRtc.disposeAll()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment