Build native mobile streaming apps with camera capture, audio handling, and network optimization. Includes complete Swift and Kotlin code examples.
Build streaming apps for iPhone and iPad using Swift and AVFoundation
Build streaming apps for Android devices using Kotlin and Camera2 API
Create a new iOS App project in Xcode
File → New → Project → iOS → App
Add required capabilities in Info.plist
<key>NSCameraUsageDescription</key>
<string>We need camera access to stream video</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need microphone access to stream audio</string>Install WAVE iOS SDK via CocoaPods
# Podfile
platform :ios, '14.0'
target 'YourApp' do
use_frameworks!
pod 'WAVEStreaming', '~> 1.0'
endThen run: pod install
Configure AVFoundation camera capture
import UIKit
import AVFoundation
import WAVEStreaming
class StreamViewController: UIViewController {
// MARK: - Properties
private let captureSession = AVCaptureSession()
private var videoDevice: AVCaptureDevice?
private var audioDevice: AVCaptureDevice?
private let previewLayer = AVCaptureVideoPreviewLayer()
private var waveStreamer: WAVEStreamer?
// MARK: - UI Elements
private let startButton = UIButton()
private let stopButton = UIButton()
private let switchCameraButton = UIButton()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupCamera()
setupWAVEStreaming()
}
// MARK: - Camera Setup
private func setupCamera() {
captureSession.beginConfiguration()
// Set session preset for quality
if captureSession.canSetSessionPreset(.hd1920x1080) {
captureSession.sessionPreset = .hd1920x1080
}
// Add video input
guard let videoDevice = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back
) else {
print("Failed to get video device")
return
}
self.videoDevice = videoDevice
do {
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
if captureSession.canAddInput(videoInput) {
captureSession.addInput(videoInput)
}
} catch {
print("Error adding video input: \(error)")
return
}
// Add audio input
guard let audioDevice = AVCaptureDevice.default(for: .audio) else {
print("Failed to get audio device")
return
}
self.audioDevice = audioDevice
do {
let audioInput = try AVCaptureDeviceInput(device: audioDevice)
if captureSession.canAddInput(audioInput) {
captureSession.addInput(audioInput)
}
} catch {
print("Error adding audio input: \(error)")
return
}
// Setup preview layer
previewLayer.session = captureSession
previewLayer.videoGravity = .resizeAspectFill
previewLayer.frame = view.bounds
view.layer.insertSublayer(previewLayer, at: 0)
captureSession.commitConfiguration()
// Start preview
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.captureSession.startRunning()
}
}
// MARK: - WAVE Streaming Setup
private func setupWAVEStreaming() {
let config = WAVEStreamerConfig(
streamKey: "your-stream-key-here",
serverURL: "rtmp://ingest.wave.stream/live",
videoBitrate: 6000000, // 6 Mbps for 1080p
audioBitrate: 128000, // 128 Kbps
videoWidth: 1920,
videoHeight: 1080,
fps: 30
)
waveStreamer = WAVEStreamer(config: config)
waveStreamer?.delegate = self
}
// MARK: - Actions
@objc private func startStreaming() {
waveStreamer?.connect(captureSession: captureSession)
startButton.isEnabled = false
stopButton.isEnabled = true
}
@objc private func stopStreaming() {
waveStreamer?.disconnect()
startButton.isEnabled = true
stopButton.isEnabled = false
}
@objc private func switchCamera() {
captureSession.beginConfiguration()
// Remove current video input
if let currentInput = captureSession.inputs.first(
where: { ($0 as? AVCaptureDeviceInput)?.device.hasMediaType(.video) == true }
) {
captureSession.removeInput(currentInput)
}
// Get opposite camera
let newPosition: AVCaptureDevice.Position = videoDevice?.position == .back ? .front : .back
guard let newVideoDevice = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: newPosition
) else {
captureSession.commitConfiguration()
return
}
do {
let newVideoInput = try AVCaptureDeviceInput(device: newVideoDevice)
if captureSession.canAddInput(newVideoInput) {
captureSession.addInput(newVideoInput)
videoDevice = newVideoDevice
}
} catch {
print("Error switching camera: \(error)")
}
captureSession.commitConfiguration()
}
// MARK: - UI Setup
private func setupUI() {
view.backgroundColor = .black
// Configure buttons
startButton.setTitle("Start Streaming", for: .normal)
startButton.backgroundColor = .systemGreen
startButton.layer.cornerRadius = 8
startButton.addTarget(self, action: #selector(startStreaming), for: .touchUpInside)
stopButton.setTitle("Stop Streaming", for: .normal)
stopButton.backgroundColor = .systemRed
stopButton.layer.cornerRadius = 8
stopButton.isEnabled = false
stopButton.addTarget(self, action: #selector(stopStreaming), for: .touchUpInside)
switchCameraButton.setTitle("Switch Camera", for: .normal)
switchCameraButton.backgroundColor = .systemBlue
switchCameraButton.layer.cornerRadius = 8
switchCameraButton.addTarget(self, action: #selector(switchCamera), for: .touchUpInside)
// Add to view and layout
let stackView = UIStackView(arrangedSubviews: [startButton, stopButton, switchCameraButton])
stackView.axis = .horizontal
stackView.distribution = .fillEqually
stackView.spacing = 12
stackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
stackView.heightAnchor.constraint(equalToConstant: 50)
])
}
}
// MARK: - WAVE Streamer Delegate
extension StreamViewController: WAVEStreamerDelegate {
func streamerDidConnect(_ streamer: WAVEStreamer) {
print("✅ Connected to WAVE")
DispatchQueue.main.async {
// Update UI
}
}
func streamer(_ streamer: WAVEStreamer, didDisconnectWithError error: Error?) {
print("❌ Disconnected: \(error?.localizedDescription ?? "Unknown error")")
DispatchQueue.main.async {
self.startButton.isEnabled = true
self.stopButton.isEnabled = false
}
}
func streamer(_ streamer: WAVEStreamer, didUpdateStats stats: WAVEStreamStats) {
print("📊 Bitrate: \(stats.currentBitrate / 1000) Kbps, FPS: \(stats.currentFPS)")
}
}Handle network changes and optimize for cellular
import Network
class NetworkMonitor {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
var onNetworkChange: ((NWPath) -> Void)?
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
self?.onNetworkChange?(path)
if path.status == .satisfied {
if path.usesInterfaceType(.wifi) {
print("📶 Connected via WiFi - using high quality")
self?.adjustQualityForWiFi()
} else if path.usesInterfaceType(.cellular) {
print("📱 Connected via Cellular - optimizing bitrate")
self?.adjustQualityForCellular()
}
} else {
print("❌ No network connection")
}
}
monitor.start(queue: queue)
}
private func adjustQualityForWiFi() {
// Use full 1080p quality
let config = WAVEStreamerConfig(
streamKey: "your-key",
serverURL: "rtmp://ingest.wave.stream/live",
videoBitrate: 6000000, // 6 Mbps
audioBitrate: 128000,
videoWidth: 1920,
videoHeight: 1080,
fps: 30
)
// Update streamer config
}
private func adjustQualityForCellular() {
// Reduce to 720p to save data
let config = WAVEStreamerConfig(
streamKey: "your-key",
serverURL: "rtmp://ingest.wave.stream/live",
videoBitrate: 3000000, // 3 Mbps
audioBitrate: 96000,
videoWidth: 1280,
videoHeight: 720,
fps: 30
)
// Update streamer config
}
func stopMonitoring() {
monitor.cancel()
}
}Enable background audio in Xcode project settings:
// Prevent screen from sleeping during stream
UIApplication.shared.isIdleTimerDisabled = true
// Monitor battery level
UIDevice.current.isBatteryMonitoringEnabled = true
NotificationCenter.default.addObserver(
self,
selector: #selector(batteryLevelDidChange),
name: UIDevice.batteryLevelDidChangeNotification,
object: nil
)
@objc func batteryLevelDidChange() {
let batteryLevel = UIDevice.current.batteryLevel
if batteryLevel < 0.2 {
// Battery below 20% - reduce quality
print("⚠️ Low battery - reducing stream quality")
adjustQualityForLowBattery()
}
}Add permissions to AndroidManifest.xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />Add WAVE SDK dependency
// build.gradle (app)
dependencies {
implementation 'com.wave:streaming-sdk:1.0.0'
implementation 'androidx.camera:camera-core:1.3.0'
implementation 'androidx.camera:camera-camera2:1.3.0'
implementation 'androidx.camera:camera-lifecycle:1.3.0'
implementation 'androidx.camera:camera-view:1.3.0'
}Complete streaming activity with Camera2 API
package com.yourapp.streaming
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import com.wave.streaming.*
import kotlinx.android.synthetic.main.activity_stream.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class StreamActivity : AppCompatActivity(), WAVEStreamerListener {
private var camera: Camera? = null
private var preview: Preview? = null
private var videoCapture: VideoCapture? = null
private lateinit var cameraExecutor: ExecutorService
private var waveStreamer: WAVEStreamer? = null
private var isStreaming = false
// Permission launcher
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
if (permissions.all { it.value }) {
startCamera()
} else {
Toast.makeText(this, "Permissions required", Toast.LENGTH_SHORT).show()
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_stream)
cameraExecutor = Executors.newSingleThreadExecutor()
// Request permissions
if (allPermissionsGranted()) {
startCamera()
} else {
requestPermissionLauncher.launch(REQUIRED_PERMISSIONS)
}
// Setup buttons
btnStartStream.setOnClickListener { startStreaming() }
btnStopStream.setOnClickListener { stopStreaming() }
btnSwitchCamera.setOnClickListener { switchCamera() }
// Initialize WAVE Streamer
setupWAVEStreaming()
}
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// Preview
preview = Preview.Builder()
.setTargetResolution(android.util.Size(1920, 1080))
.build()
.also {
it.setSurfaceProvider(viewFinder.surfaceProvider)
}
// Video capture
val recorder = Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.FHD))
.build()
videoCapture = VideoCapture.withOutput(recorder)
// Select back camera by default
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
// Unbind previous use cases
cameraProvider.unbindAll()
// Bind use cases to camera
camera = cameraProvider.bindToLifecycle(
this,
cameraSelector,
preview,
videoCapture
)
} catch (exc: Exception) {
exc.printStackTrace()
}
}, ContextCompat.getMainExecutor(this))
}
private fun setupWAVEStreaming() {
val config = WAVEStreamerConfig(
streamKey = "your-stream-key-here",
serverURL = "rtmp://ingest.wave.stream/live",
videoBitrate = 6000000, // 6 Mbps for 1080p
audioBitrate = 128000, // 128 Kbps
videoWidth = 1920,
videoHeight = 1080,
fps = 30
)
waveStreamer = WAVEStreamer(this, config)
waveStreamer?.setListener(this)
}
private fun startStreaming() {
if (isStreaming) return
videoCapture?.let { capture ->
waveStreamer?.connect(capture)
isStreaming = true
btnStartStream.isEnabled = false
btnStopStream.isEnabled = true
Toast.makeText(this, "Streaming started", Toast.LENGTH_SHORT).show()
}
}
private fun stopStreaming() {
if (!isStreaming) return
waveStreamer?.disconnect()
isStreaming = false
btnStartStream.isEnabled = true
btnStopStream.isEnabled = false
Toast.makeText(this, "Streaming stopped", Toast.LENGTH_SHORT).show()
}
private fun switchCamera() {
// Implementation for switching between front and back camera
val cameraProvider = ProcessCameraProvider.getInstance(this).get()
val newCameraSelector = if (camera?.cameraInfo?.lensFacing == CameraSelector.LENS_FACING_BACK) {
CameraSelector.DEFAULT_FRONT_CAMERA
} else {
CameraSelector.DEFAULT_BACK_CAMERA
}
try {
cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(
this,
newCameraSelector,
preview,
videoCapture
)
} catch (exc: Exception) {
exc.printStackTrace()
}
}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
}
// MARK: - WAVE Streamer Listener
override fun onStreamerConnected(streamer: WAVEStreamer) {
runOnUiThread {
Toast.makeText(this, "✅ Connected to WAVE", Toast.LENGTH_SHORT).show()
}
}
override fun onStreamerDisconnected(streamer: WAVEStreamer, error: Throwable?) {
runOnUiThread {
Toast.makeText(
this,
"❌ Disconnected: ${error?.message ?: "Unknown error"}",
Toast.LENGTH_SHORT
).show()
isStreaming = false
btnStartStream.isEnabled = true
btnStopStream.isEnabled = false
}
}
override fun onStreamerStatsUpdated(streamer: WAVEStreamer, stats: WAVEStreamStats) {
runOnUiThread {
tvBitrate.text = "Bitrate: ${stats.currentBitrate / 1000} Kbps"
tvFPS.text = "FPS: ${stats.currentFPS}"
}
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
waveStreamer?.disconnect()
}
companion object {
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
}
}<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/controlsLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<LinearLayout
android:id="@+id/statsLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:background="#80000000"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/tvBitrate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Bitrate: 0 Kbps"
android:textColor="@android:color/white"
android:layout_marginEnd="16dp" />
<TextView
android:id="@+id/tvFPS"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="FPS: 0"
android:textColor="@android:color/white" />
</LinearLayout>
<LinearLayout
android:id="@+id/controlsLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<Button
android:id="@+id/btnStartStream"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Start Streaming"
android:layout_marginEnd="8dp"
android:backgroundTint="#4CAF50" />
<Button
android:id="@+id/btnStopStream"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Stop Streaming"
android:enabled="false"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:backgroundTint="#F44336" />
<Button
android:id="@+id/btnSwitchCamera"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Switch Camera"
android:layout_marginStart="8dp"
android:backgroundTint="#2196F3" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>