LiveKit Ingress Service - WebRTC Input (WHIP)
Mix.install([
{:livekit, path: "../.."},
{:kino, "~> 0.12"},
{:jason, "~> 1.4"}
])
Introduction to WebRTC Ingress (WHIP)
This Livebook explores WebRTC-HTTP Ingress Protocol (WHIP) for low-latency streaming with LiveKit. WHIP is a modern alternative to RTMP that offers:
- π Ultra-low latency (sub-second)
- π Better firewall traversal via WebRTC
- π Built-in security with DTLS/SRTP
- π± Browser-native support without plugins
- ποΈ Advanced codec support (AV1, VP9, H.264)
What Youβll Learn:
- π§ Setting up WHIP ingress endpoints
- π Browser-based streaming with JavaScript
- π± Mobile WebRTC streaming considerations
- β‘ Optimizing for ultra-low latency
- π Debugging WebRTC connections
- π Monitoring WebRTC performance
Configuration & Client Setup
# LiveKit configuration form
config_form = Kino.Control.form(
[
api_key: Kino.Input.password("LiveKit API Key"),
api_secret: Kino.Input.password("LiveKit API Secret"),
url: Kino.Input.text("LiveKit Server URL", default: "wss://your-server.livekit.cloud"),
room_name: Kino.Input.text("Target Room Name", default: "whip-demo-room")
],
submit: "Connect to LiveKit"
)
# Establish connection and store configuration
config = Kino.Control.read(config_form)
Process.put(:config, config)
case Livekit.IngressServiceClient.new(config.url, config.api_key, config.api_secret) do
{:ok, client} ->
Process.put(:client, client)
IO.puts("β
Connected to LiveKit Ingress Service!")
IO.puts("π― Target room: #{config.room_name}")
IO.puts("π Server: #{config.url}")
{:error, reason} ->
IO.puts("β Connection failed: #{reason}")
IO.puts("Please verify your credentials and server URL.")
end
Creating a WHIP Ingress Endpoint
Letβs create a WebRTC ingress endpoint optimized for low-latency streaming:
# WHIP Ingress Configuration Form
whip_form = Kino.Control.form(
[
name: Kino.Input.text("Stream Name", default: "whip-stream-#{System.system_time(:second)}"),
participant_identity: Kino.Input.text("Streamer Identity", default: "webrtc-streamer"),
participant_name: Kino.Input.text("Display Name", default: "WebRTC Broadcaster"),
participant_metadata: Kino.Input.textarea("Metadata (JSON)", default: """
{
"streaming_protocol": "WHIP",
"codec_preference": "VP9",
"latency_mode": "ultra_low",
"browser": "Chrome/Safari"
}"""),
enable_transcoding: Kino.Input.checkbox("Enable Transcoding", default: false)
],
submit: "Create WHIP Ingress"
)
# Create the WHIP ingress endpoint
whip_params = Kino.Control.read(whip_form)
client = Process.get(:client)
config = Process.get(:config)
# Validate and prepare metadata
metadata = try do
Jason.decode!(whip_params.participant_metadata)
whip_params.participant_metadata
rescue
_ -> "{\"note\": \"Invalid JSON, using fallback\"}"
end
request = %Livekit.CreateIngressRequest{
input_type: :WHIP_INPUT,
name: whip_params.name,
room_name: config.room_name,
participant_identity: whip_params.participant_identity,
participant_name: whip_params.participant_name,
participant_metadata: metadata,
enable_transcoding: whip_params.enable_transcoding
}
case Livekit.IngressServiceClient.create_ingress(client, request) do
{:ok, ingress} ->
Process.put(:whip_ingress, ingress)
IO.puts("π WHIP Ingress Created Successfully!")
IO.puts("=" |> String.duplicate(50))
IO.puts("π‘ Ingress ID: #{ingress.ingress_id}")
IO.puts("π·οΈ Name: #{ingress.name}")
IO.puts("π WHIP URL: #{ingress.url}")
IO.puts("π Room: #{ingress.room_name}")
IO.puts("π€ Participant: #{ingress.participant_identity}")
IO.puts("π Display Name: #{ingress.participant_name}")
IO.puts("βοΈ Transcoding: #{ingress.enable_transcoding}")
IO.puts("π Metadata: #{ingress.participant_metadata}")
IO.puts("=" |> String.duplicate(50))
IO.puts("")
IO.puts("β‘ Low-latency WebRTC endpoint ready!")
{:error, reason} ->
IO.puts("β Failed to create WHIP ingress: #{inspect(reason)}")
end
Browser WebRTC Streaming Code Generator
Letβs generate JavaScript code for browser-based WebRTC streaming:
ingress = Process.get(:whip_ingress)
if ingress do
IO.puts("π Browser WebRTC Streaming Code")
IO.puts("=" |> String.duplicate(50))
IO.puts("")
IO.puts("Copy and paste this HTML file to test WebRTC streaming:")
IO.puts("")
html_code = """
WebRTC WHIP Streaming to LiveKit
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
video {
width: 100%;
max-width: 640px;
border: 1px solid #ccc;
border-radius: 8px;
}
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover { background: #0056b3; }
button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.status {
padding: 10px;
border-radius: 5px;
margin: 10px 0;
}
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
.status.warning { background: #fff3cd; color: #856404; }
π WebRTC WHIP Streaming to LiveKit
π± Click "Start Camera" to begin streaming
Start Camera
Stop Streaming
Toggle Audio
Toggle Video
π Stream Information:
- WHIP URL:
#{ingress.url}
Advanced WebRTC Configuration
Letβs explore advanced WebRTC configurations for different use cases:
# Advanced WebRTC configuration generator
advanced_config_form = Kino.Control.form(
[
use_case: Kino.Input.select("Use Case", [
{"Ultra Low Latency Gaming", :gaming},
{"High Quality Broadcasting", :broadcasting},
{"Mobile-Optimized Streaming", :mobile},
{"Screen Sharing", :screen_share}
]),
video_codec: Kino.Input.select("Video Codec Preference", [
{"VP9 (Best Quality)", "VP9"},
{"VP8 (Good Compatibility)", "VP8"},
{"H.264 (Universal)", "H264"}
]),
audio_codec: Kino.Input.select("Audio Codec", [
{"Opus (Recommended)", "opus"},
{"G.722", "G722"},
{"PCMU", "PCMU"}
])
],
submit: "Generate Advanced Configuration"
)
# Generate advanced WebRTC configuration
advanced_params = Kino.Control.read(advanced_config_form)
config_js = case advanced_params.use_case do
:gaming ->
"""
// Gaming-optimized WebRTC configuration
const rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const mediaConstraints = {
video: {
width: { exact: 1920 },
height: { exact: 1080 },
frameRate: { exact: 60 }, // High framerate for gaming
latency: { ideal: 0.01 } // Ultra-low latency
},
audio: {
echoCancellation: false, // Disable for gaming audio
noiseSuppression: false,
autoGainControl: false,
latency: { ideal: 0.01 }
}
};
"""
:broadcasting ->
"""
// Broadcasting-optimized WebRTC configuration
const rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
const mediaConstraints = {
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 },
facingMode: 'user'
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 48000,
channelCount: 2
}
};
"""
:mobile ->
"""
// Mobile-optimized WebRTC configuration
const rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const mediaConstraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
facingMode: { ideal: 'user' } // Front camera by default
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
};
// Mobile-specific optimizations
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
mediaConstraints.video.width = { ideal: 854 };
mediaConstraints.video.height = { ideal: 480 };
mediaConstraints.video.frameRate = { ideal: 24 };
}
"""
:screen_share ->
"""
// Screen sharing WebRTC configuration
const rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const mediaConstraints = {
video: {
cursor: 'always',
displaySurface: 'monitor',
frameRate: { ideal: 30 },
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false // Usually no system audio for screen share
};
// Use getDisplayMedia for screen capture
async function getScreenShare() {
return navigator.mediaDevices.getDisplayMedia(mediaConstraints);
}
"""
end
IO.puts("π§ Advanced WebRTC Configuration (#{advanced_params.use_case}):")
IO.puts("=" |> String.duplicate(60))
IO.puts("")
IO.puts(config_js)
IO.puts("")
IO.puts("π Codec Preferences:")
IO.puts(" Video: #{advanced_params.video_codec}")
IO.puts(" Audio: #{advanced_params.audio_codec}")
Real-time WebRTC Performance Monitor
Letβs create a monitoring system for WebRTC performance metrics:
defmodule WebRTCMonitor do
def check_ingress_status(client, ingress_id) do
case Livekit.IngressServiceClient.list_ingress(client) do
{:ok, response} ->
ingress = Enum.find(response.items, fn i -> i.ingress_id == ingress_id end)
if ingress do
%{
found: true,
ingress: ingress,
status: ingress.state && ingress.state.status,
room_id: ingress.state && ingress.state.room_id,
tracks: ingress.state && ingress.state.tracks || []
}
else
%{found: false, error: "Ingress not found"}
end
{:error, reason} ->
%{found: false, error: reason}
end
end
def format_status(status) do
case status do
:ENDPOINT_INACTIVE -> {"π΄", "Inactive"}
:ENDPOINT_BUFFERING -> {"π‘", "Buffering"}
:ENDPOINT_PUBLISHING -> {"π’", "Publishing"}
:ENDPOINT_ERROR -> {"β", "Error"}
:ENDPOINT_COMPLETE -> {"β
", "Complete"}
_ -> {"βͺ", "Unknown"}
end
end
end
# Create monitoring frame
monitor_frame = Kino.Frame.new()
# Start monitoring task
monitor_task = Task.async(fn ->
ingress = Process.get(:whip_ingress)
client = Process.get(:client)
if ingress && client do
Enum.each(1..20, fn iteration -> # Monitor for 100 seconds (20 * 5 seconds)
timestamp = DateTime.utc_now() |> DateTime.to_string()
status_info = WebRTCMonitor.check_ingress_status(client, ingress.ingress_id)
content = if status_info.found do
{emoji, status_text} = WebRTCMonitor.format_status(status_info.status)
track_count = length(status_info.tracks)
track_details = if track_count > 0 do
track_list = Enum.map_join(status_info.tracks, ", ", fn track ->
"#{track.type} (#{track.name})"
end)
"\n- **Active Tracks:** #{track_list}"
else
"\n- **Active Tracks:** None"
end
"""
## π WebRTC Ingress Monitor (Update ##{iteration})
**Last Update:** #{timestamp}
#{emoji} **Status:** #{status_text}
- **Ingress ID:** #{ingress.ingress_id}
- **WHIP URL:** `#{ingress.url}`
- **Room:** #{ingress.room_name}
- **Participant:** #{ingress.participant_identity}#{track_details}
### π WebRTC Connection Tips:
- Ensure your browser supports WebRTC
- Check that camera/microphone permissions are granted
- Verify network connectivity (WebRTC works through NAT/firewalls)
- Monitor browser console for WebRTC logs
### π§ Troubleshooting:
- If stuck on "Buffering": Check media source is active
- If "Error": Check browser console for detailed logs
- If "Inactive": Verify WHIP client is connecting to the URL
"""
else
"""
## β WebRTC Monitoring Error (Update ##{iteration})
**Last Update:** #{timestamp}
Failed to monitor ingress: #{inspect(status_info.error)}
"""
end
Kino.Frame.render(monitor_frame, Kino.Markdown.new(content))
Process.sleep(5000) # Wait 5 seconds
end)
# Final update
Kino.Frame.render(monitor_frame, Kino.Markdown.new("""
## π WebRTC Monitoring Complete
**Session finished.** Re-run this cell to start a new monitoring session.
"""))
else
Kino.Frame.render(monitor_frame, Kino.Markdown.new("""
## β οΈ WebRTC Monitoring Not Available
Please create a WHIP ingress first.
"""))
end
end)
monitor_frame
WebRTC Debugging and Diagnostics
IO.puts("π WebRTC Debugging and Diagnostics")
IO.puts("=" |> String.duplicate(50))
IO.puts("")
IO.puts("π **Browser Console Commands for WebRTC Debugging:**")
IO.puts("")
IO.puts("```javascript")
IO.puts("// Enable WebRTC logging in Chrome")
IO.puts("localStorage.setItem('debug', 'livekit*');")
IO.puts("")
IO.puts("// Check WebRTC connection stats")
IO.puts("pc.getStats().then(stats => {")
IO.puts(" stats.forEach(report => {")
IO.puts(" if (report.type === 'outbound-rtp') {")
IO.puts(" console.log('Outbound stats:', report);")
IO.puts(" }")
IO.puts(" });")
IO.puts("});")
IO.puts("")
IO.puts("// Monitor connection state changes")
IO.puts("pc.addEventListener('connectionstatechange', () => {")
IO.puts(" console.log('Connection state:', pc.connectionState);")
IO.puts("});")
IO.puts("")
IO.puts("// Monitor ICE connection state")
IO.puts("pc.addEventListener('iceconnectionstatechange', () => {")
IO.puts(" console.log('ICE connection state:', pc.iceConnectionState);")
IO.puts("});")
IO.puts("```")
IO.puts("")
IO.puts("π§ **Common WebRTC Issues and Solutions:**")
IO.puts("")
IO.puts("1οΈβ£ **WHIP Connection Fails**")
IO.puts(" β’ Check WHIP URL is accessible")
IO.puts(" β’ Verify CORS headers are set correctly")
IO.puts(" β’ Ensure HTTPS is used for secure contexts")
IO.puts(" β’ Check browser console for detailed errors")
IO.puts("")
IO.puts("2οΈβ£ **Media Access Denied**")
IO.puts(" β’ Ensure HTTPS for camera/microphone access")
IO.puts(" β’ Check browser permissions settings")
IO.puts(" β’ Try different browsers (Chrome/Firefox/Safari)")
IO.puts(" β’ Verify devices are not in use by other applications")
IO.puts("")
IO.puts("3οΈβ£ **Poor Video Quality**")
IO.puts(" β’ Adjust video constraints (resolution/framerate)")
IO.puts(" β’ Check available bandwidth")
IO.puts(" β’ Monitor WebRTC stats for packet loss")
IO.puts(" β’ Consider codec preferences (VP9 > VP8 > H.264)")
IO.puts("")
IO.puts("4οΈβ£ **Connection Timeouts**")
IO.puts(" β’ Check STUN/TURN server configuration")
IO.puts(" β’ Verify firewall/NAT settings")
IO.puts(" β’ Test with different networks")
IO.puts(" β’ Monitor ICE candidate gathering")
IO.puts("")
IO.puts("5οΈβ£ **Audio Issues**")
IO.puts(" β’ Check microphone permissions")
IO.puts(" β’ Verify audio constraints")
IO.puts(" β’ Test with different audio codecs")
IO.puts(" β’ Monitor audio levels in browser")
WebRTC vs RTMP Comparison
IO.puts("β‘ WebRTC (WHIP) vs RTMP Comparison")
IO.puts("=" |> String.duplicate(45))
IO.puts("")
comparison_data = [
{"Aspect", "WebRTC (WHIP)", "RTMP"},
{"---", "---", "---"},
{"Latency", "Sub-second (50-200ms)", "2-10 seconds"},
{"Protocol", "UDP-based WebRTC", "TCP-based RTMP"},
{"Firewall", "NAT/Firewall friendly", "May need port forwarding"},
{"Browser Support", "Native support", "Requires Flash/plugin"},
{"Encoding", "Hardware accelerated", "Software encoding"},
{"Adaptive", "Built-in adaptation", "Fixed bitrate"},
{"Complexity", "More complex setup", "Simpler configuration"},
{"Streaming Software", "Limited support", "Universal support (OBS)"},
{"Mobile", "Excellent", "Good with apps"},
{"Quality", "Dynamic quality", "Consistent quality"}
]
for [aspect, webrtc, rtmp] <- comparison_data do
IO.puts("| #{String.pad_trailing(aspect, 15)} | #{String.pad_trailing(webrtc, 20)} | #{rtmp}")
end
IO.puts("")
IO.puts("π **When to Choose WebRTC (WHIP):**")
IO.puts(" β
Ultra-low latency requirements")
IO.puts(" β
Browser-based streaming")
IO.puts(" β
Interactive applications")
IO.puts(" β
Mobile streaming apps")
IO.puts(" β
Firewall/NAT traversal needed")
IO.puts("")
IO.puts("π **When to Choose RTMP:**")
IO.puts(" β
Streaming software compatibility")
IO.puts(" β
Consistent video quality needed")
IO.puts(" β
Simpler setup requirements")
IO.puts(" β
Long-form content streaming")
IO.puts(" β
Established workflows")
Performance Optimization for WebRTC
# WebRTC optimization recommendations generator
optimization_form = Kino.Control.form(
[
connection_type: Kino.Input.select("Network Connection", [
{"High-speed WiFi/Ethernet", :high_speed},
{"Mobile 4G/5G", :mobile},
{"Limited bandwidth", :limited}
]),
device_type: Kino.Input.select("Streaming Device", [
{"Desktop/Laptop", :desktop},
{"Mobile phone", :mobile_device},
{"Tablet", :tablet}
]),
content_type: Kino.Input.select("Content Type", [
{"Live person (talking head)", :talking_head},
{"Screen sharing", :screen_share},
{"Gaming/high motion", :gaming}
])
],
submit: "Get Optimization Recommendations"
)
# Generate optimization recommendations
opt_params = Kino.Control.read(optimization_form)
IO.puts("π WebRTC Optimization Recommendations")
IO.puts("=" |> String.duplicate(50))
IO.puts("")
# Connection type optimizations
IO.puts("π **Network Optimization:**")
case opt_params.connection_type do
:high_speed ->
IO.puts(" β’ Use high resolution: 1920x1080")
IO.puts(" β’ High framerate: 30-60 fps")
IO.puts(" β’ Use VP9 codec for best quality")
IO.puts(" β’ Enable hardware acceleration")
:mobile ->
IO.puts(" β’ Moderate resolution: 1280x720")
IO.puts(" β’ Standard framerate: 30 fps")
IO.puts(" β’ Use adaptive bitrate")
IO.puts(" β’ Monitor data usage")
:limited ->
IO.puts(" β’ Lower resolution: 854x480")
IO.puts(" β’ Reduced framerate: 15-24 fps")
IO.puts(" β’ Use H.264 for efficiency")
IO.puts(" β’ Enable aggressive compression")
end
IO.puts("")
# Device type optimizations
IO.puts("π± **Device Optimization:**")
case opt_params.device_type do
:desktop ->
IO.puts(" β’ Use hardware encoding (NVENC/QuickSync)")
IO.puts(" β’ Multiple core utilization")
IO.puts(" β’ High-quality audio processing")
IO.puts(" β’ Multiple camera support")
:mobile_device ->
IO.puts(" β’ Enable low-power mode")
IO.puts(" β’ Use mobile-optimized codecs")
IO.puts(" β’ Monitor battery usage")
IO.puts(" β’ Thermal management")
:tablet ->
IO.puts(" β’ Balance quality and battery")
IO.puts(" β’ Optimize for tablet orientation")
IO.puts(" β’ Touch-friendly controls")
IO.puts(" β’ WiFi optimization")
end
IO.puts("")
# Content type optimizations
IO.puts("π₯ **Content Optimization:**")
case opt_params.content_type do
:talking_head ->
IO.puts(" β’ Focus on face detection")
IO.puts(" β’ Optimize for speech audio")
IO.puts(" β’ Moderate motion settings")
IO.puts(" β’ Good lighting important")
:screen_share ->
IO.puts(" β’ Optimize for text clarity")
IO.puts(" β’ Use screen-specific codecs")
IO.puts(" β’ Lower framerates acceptable")
IO.puts(" β’ Focus on specific regions")
:gaming ->
IO.puts(" β’ High framerate essential")
IO.puts(" β’ Low latency priority")
IO.puts(" β’ Disable post-processing")
IO.puts(" β’ Hardware encoding critical")
end
IO.puts("")
IO.puts("π‘ **Universal WebRTC Tips:**")
IO.puts(" β’ Test with multiple browsers")
IO.puts(" β’ Monitor WebRTC statistics")
IO.puts(" β’ Use TURN servers for NAT traversal")
IO.puts(" β’ Implement automatic quality adaptation")
IO.puts(" β’ Provide user feedback on connection quality")
Cleanup and Resource Management
# WebRTC ingress cleanup
cleanup_form = Kino.Control.form(
[
action: Kino.Input.select("Cleanup Action", [
{"List all WHIP ingress endpoints", :list_whip},
{"Delete current WHIP ingress", :delete_current},
{"Delete all test WHIP endpoints", :delete_test}
])
],
submit: "Execute Cleanup"
)
# Execute cleanup
cleanup_action = Kino.Control.read(cleanup_form)
client = Process.get(:client)
case cleanup_action.action do
:list_whip ->
case Livekit.IngressServiceClient.list_ingress(client) do
{:ok, response} ->
whip_ingress = Enum.filter(response.items, fn ingress ->
ingress.input_type == :WHIP_INPUT
end)
IO.puts("π WHIP Ingress Endpoints (#{length(whip_ingress)} total):")
for ingress <- whip_ingress do
{emoji, status_text} = WebRTCMonitor.format_status(ingress.state && ingress.state.status)
IO.puts(" #{emoji} #{ingress.name}")
IO.puts(" ID: #{ingress.ingress_id}")
IO.puts(" URL: #{ingress.url}")
IO.puts(" Room: #{ingress.room_name}")
IO.puts(" Status: #{status_text}")
IO.puts("")
end
{:error, reason} ->
IO.puts("β Failed to list WHIP ingress: #{inspect(reason)}")
end
:delete_current ->
ingress = Process.get(:whip_ingress)
if ingress do
request = %Livekit.DeleteIngressRequest{ingress_id: ingress.ingress_id}
case Livekit.IngressServiceClient.delete_ingress(client, request) do
{:ok, deleted} ->
IO.puts("β
Deleted current WHIP ingress: #{deleted.name}")
Process.delete(:whip_ingress)
{:error, reason} ->
IO.puts("β Failed to delete: #{inspect(reason)}")
end
else
IO.puts("β οΈ No current WHIP ingress to delete")
end
:delete_test ->
case Livekit.IngressServiceClient.list_ingress(client) do
{:ok, response} ->
test_whip = Enum.filter(response.items, fn ingress ->
ingress.input_type == :WHIP_INPUT &&
(String.contains?(ingress.name, "whip") ||
String.contains?(ingress.name, "test") ||
String.contains?(ingress.name, "demo"))
end)
if Enum.empty?(test_whip) do
IO.puts("β¨ No test WHIP ingress to clean up")
else
IO.puts("π§Ή Cleaning up #{length(test_whip)} test WHIP endpoints...")
for ingress <- test_whip do
request = %Livekit.DeleteIngressRequest{ingress_id: ingress.ingress_id}
case Livekit.IngressServiceClient.delete_ingress(client, request) do
{:ok, _} ->
IO.puts(" β
Deleted: #{ingress.name}")
{:error, reason} ->
IO.puts(" β Failed to delete #{ingress.name}: #{inspect(reason)}")
end
end
end
{:error, reason} ->
IO.puts("β Failed to cleanup: #{inspect(reason)}")
end
end
Summary and Next Steps
IO.puts("π WebRTC Ingress Tutorial Complete!")
IO.puts("=" |> String.duplicate(50))
IO.puts("")
IO.puts("β
**What You've Accomplished:**")
IO.puts(" β’ Created WebRTC (WHIP) ingress endpoints")
IO.puts(" β’ Generated browser-based streaming code")
IO.puts(" β’ Learned advanced WebRTC configurations")
IO.puts(" β’ Set up real-time performance monitoring")
IO.puts(" β’ Mastered WebRTC debugging techniques")
IO.puts(" β’ Optimized for different use cases")
IO.puts("")
IO.puts("π **Recommended Next Steps:**")
IO.puts(" 1. Test the generated HTML file in your browser")
IO.puts(" 2. Explore mobile WebRTC streaming applications")
IO.puts(" 3. Try the File Processing Livebook for batch ingestion")
IO.puts(" 4. Set up automated ingress management workflows")
IO.puts(" 5. Implement custom WebRTC applications")
IO.puts("")
IO.puts("π **Additional Resources:**")
IO.puts(" β’ WebRTC Spec: https://webrtc.org/")
IO.puts(" β’ WHIP Draft: https://datatracker.ietf.org/doc/draft-ietf-wish-whip/")
IO.puts(" β’ LiveKit WebRTC Guide: https://docs.livekit.io/")
IO.puts(" β’ Browser WebRTC APIs: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API")