Created
August 15, 2025 05:28
-
-
Save mxvsh/e58037832d241cbe7afb38cbd4e3bdaa to your computer and use it in GitHub Desktop.
Android TV WebRTC Demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| dependencies { | |
| implementation("io.getstream:stream-webrtc-android:1.3.8")} | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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