android/app/src/main/java/com/srtk/airlink/CameraStreamer.kt
2025-11-30 14:42:37 +05:30

467 lines
No EOL
19 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.srtk.airlink
import android.annotation.SuppressLint
import android.content.Context
import android.hardware.camera2.*
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.util.Range
import android.util.Size
import android.view.Surface
import androidx.annotation.RequiresApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlin.math.min
data class VideoConfig(val width: Int, val height: Int, val fps: Int) {
val aspectRatio: String
get() {
val ratio = width.toDouble() / height.toDouble()
return when {
abs(ratio - 1.77) < 0.05 -> "16:9"
abs(ratio - 1.33) < 0.05 -> "4:3"
abs(ratio - 2.0) < 0.1 -> "18:9"
abs(ratio - 2.16) < 0.1 -> "19.5:9"
abs(ratio - 1.0) < 0.05 -> "1:1"
else -> String.format("%.1f:1", ratio)
}
}
override fun toString() = "${height}p"
fun toDetailedString() = "${width}×${height} @ ${fps}fps"
}
data class CameraCapabilities(
val supportedConfigs: List<VideoConfig>,
val maxZoom: Float,
val hasFlash: Boolean,
val hasFrontCamera: Boolean,
val supportedWhiteBalance: List<WhiteBalance>,
val focusRange: Range<Float>?,
val exposureRange: Range<Int>?
)
enum class WhiteBalance(val value: Int, val label: String) {
AUTO(CameraMetadata.CONTROL_AWB_MODE_AUTO, "Auto"),
WARM(CameraMetadata.CONTROL_AWB_MODE_INCANDESCENT, "Warm"),
COOL(CameraMetadata.CONTROL_AWB_MODE_FLUORESCENT, "Cool")
}
class CameraStreamer(private val context: Context) {
private val TAG = "CameraStreamer"
private val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
private val cameraThread = HandlerThread("CamThread").apply { start() }
private val camHandler = Handler(cameraThread.looper)
private var cameraDevice: CameraDevice? = null
private var captureSession: CameraCaptureSession? = null
private var requestBuilder: CaptureRequest.Builder? = null
private var mediaCodec: MediaCodec? = null
private var socket: DatagramSocket? = null
private var currentIp: InetAddress? = null
private var currentPort: Int = 0
@Volatile private var isStreaming = false
private val restartLock = Semaphore(1)
private var currentConfig = VideoConfig(1280, 720, 30)
private var zoomRatio = 1f
private var flashMode = false
private var wbMode = CameraMetadata.CONTROL_AWB_MODE_AUTO
private var focusMode = CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO
private var focusDistance = 0f
private var exposureCompensation = 0
private var onErrorCallback: ((String) -> Unit)? = null
private var connectionManager: ConnectionManager? = null
fun setConnectionManager(manager: ConnectionManager) { connectionManager = manager }
fun setErrorCallback(callback: (String) -> Unit) { onErrorCallback = callback }
private fun sendControl(msg: String) {
val ip = currentIp ?: return
val port = currentPort
if (port == 0) return
Thread {
try {
val s = socket ?: DatagramSocket()
val data = msg.toByteArray()
for(i in 1..3) {
s.send(DatagramPacket(data, data.size, ip, port))
Thread.sleep(5)
}
if (socket == null) s.close()
} catch (e: Exception) {}
}.start()
}
/**
* Actually test if encoder can handle this resolution by trying to configure it
*/
private fun canEncodeResolution(width: Int, height: Int, fps: Int): Boolean {
var codec: MediaCodec? = null
return try {
val bitrate = when {
width * height >= 1920 * 1080 -> 4_000_000
width * height >= 1280 * 720 -> 2_500_000
else -> 1_500_000
}
val format = MediaFormat.createVideoFormat(
MediaFormat.MIMETYPE_VIDEO_AVC,
width,
height
).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
}
codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.reset() // Don't actually start, just test config
true
} catch (e: Exception) {
Log.d(TAG, "${width}x${height} @ ${fps}fps - Encoder rejected: ${e.message}")
false
} finally {
try {
codec?.release()
} catch (e: Exception) {}
}
}
suspend fun getCameraCapabilities(useFront: Boolean): CameraCapabilities = withContext(Dispatchers.Default) {
val camId = getCameraId(useFront)
val chars = manager.getCameraCharacteristics(camId)
val map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
// Get all sizes supported by camera for MediaCodec
val cameraSizes = map.getOutputSizes(MediaCodec::class.java)
.filter { it.width <= 1920 && it.height <= 1080 }
.toSet()
Log.d(TAG, "Testing ${cameraSizes.size} camera resolutions against encoder...")
val validConfigs = mutableListOf<VideoConfig>()
// Define common resolutions to prioritize
// val commonResolutions = listOf(
// Size(1920, 1080), // 1080p 16:9
// Size(1280, 720), // 720p 16:9
// Size(960, 720), // 720p 4:3
// Size(640, 480), // 480p 4:3
// Size(854, 480), // 480p 16:9
// Size(1440, 1080), // 1080p 4:3
// Size(800, 600), // 600p 4:3
// Size(320, 240), // 240p 4:3
// )
// Test common resolutions first
// for (size in commonResolutions) {
// if (!cameraSizes.contains(size)) continue
//
// val minFrameDuration = map.getOutputMinFrameDuration(MediaCodec::class.java, size)
// val maxFps = if (minFrameDuration > 0) {
// (1_000_000_000.0 / minFrameDuration).toInt()
// } else 30
//
// val targetFps = min(30, maxFps)
//
// if (targetFps >= 15 && canEncodeResolution(size.width, size.height, targetFps)) {
// validConfigs.add(VideoConfig(size.width, size.height, targetFps))
// Log.d(TAG, "✓ ${size.width}x${size.height} @ ${targetFps}fps")
// }
// }
// Test remaining camera sizes that aren't in common list
for (size in cameraSizes) {
// if (commonResolutions.contains(size)) continue
if (validConfigs.any { it.width == size.width && it.height == size.height }) continue
val ratio = size.width.toDouble() / size.height
// Only accept common aspect ratios
val isCommonAspectRatio = listOf(1.77, 1.33, 2.0, 2.16, 1.0)
.any { abs(ratio - it) < 0.1 }
if (!isCommonAspectRatio) continue
val minFrameDuration = map.getOutputMinFrameDuration(MediaCodec::class.java, size)
val maxFps = if (minFrameDuration > 0) {
(1_000_000_000.0 / minFrameDuration).toInt()
} else 30
val targetFps = min(30, maxFps)
if (targetFps >= 15 && canEncodeResolution(size.width, size.height, targetFps)) {
validConfigs.add(VideoConfig(size.width, size.height, targetFps))
Log.d(TAG, "${size.width}x${size.height} @ ${targetFps}fps")
}
}
// Fallback if nothing found (this should never happen on real devices)
if (validConfigs.isEmpty()) {
Log.w(TAG, "No compatible resolutions found! Adding fallback...")
// Try absolute minimum safe resolution
if (canEncodeResolution(640, 480, 30)) {
validConfigs.add(VideoConfig(640, 480, 30))
} else if (canEncodeResolution(320, 240, 30)) {
validConfigs.add(VideoConfig(320, 240, 30))
}
}
val sortedConfigs = validConfigs
.sortedByDescending { it.width * it.height }
.distinctBy { "${it.width}x${it.height}" }
Log.d(TAG, "✓ Final supported resolutions: ${sortedConfigs.size} configs")
sortedConfigs.forEach {
Log.d(TAG, " - ${it.toDetailedString()}")
}
CameraCapabilities(
sortedConfigs,
chars.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f,
chars.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true,
manager.cameraIdList.any {
manager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) ==
CameraCharacteristics.LENS_FACING_FRONT
},
listOf(WhiteBalance.AUTO, WhiteBalance.WARM, WhiteBalance.COOL),
chars.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE)?.let {
if (it > 0) Range(0f, it) else null
},
chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE)
)
}
@RequiresApi(Build.VERSION_CODES.Q)
fun start(ip: InetAddress, port: Int, config: VideoConfig, useFront: Boolean) {
currentConfig = config
currentIp = ip
currentPort = port
val camId = getCameraId(useFront)
camHandler.post {
try {
if (restartLock.tryAcquire(2500, TimeUnit.MILLISECONDS)) {
try {
stopInternal(sendBye = false)
Thread.sleep(100)
startInternal(ip, port, camId)
} finally {
restartLock.release()
}
}
} catch (e: Exception) {
Log.e(TAG, "Start error", e)
onErrorCallback?.invoke("Start failed: ${e.message}")
}
}
}
@RequiresApi(Build.VERSION_CODES.R)
fun setZoom(z: Float) { zoomRatio = z; updateSession() }
@RequiresApi(Build.VERSION_CODES.R)
fun setTorch(on: Boolean) { flashMode = on; updateSession() }
@RequiresApi(Build.VERSION_CODES.R)
fun setWhiteBalance(wb: WhiteBalance) { wbMode = wb.value; updateSession() }
@RequiresApi(Build.VERSION_CODES.R)
fun setFocusMode(auto: Boolean) {
focusMode = if (auto) CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO
else CameraMetadata.CONTROL_AF_MODE_OFF
updateSession()
}
@RequiresApi(Build.VERSION_CODES.R)
fun setFocusDistance(distance: Float) {
focusDistance = distance
focusMode = CameraMetadata.CONTROL_AF_MODE_OFF
updateSession()
}
@RequiresApi(Build.VERSION_CODES.R)
fun setExposure(value: Int) { exposureCompensation = value; updateSession() }
fun stopCapture() {
sendControl("PAUSE")
camHandler.post {
try {
captureSession?.stopRepeating()
} catch(e: Exception) {}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("MissingPermission")
private fun startInternal(ip: InetAddress, port: Int, camId: String) {
try {
socket = DatagramSocket()
isStreaming = true
val width = currentConfig.width
val height = currentConfig.height
val fps = currentConfig.fps
val bitrate = when {
width * height >= 1920 * 1080 -> 4_000_000
width * height >= 1280 * 720 -> 2_500_000
else -> 1_500_000
}
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
}
mediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
try {
mediaCodec?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
} catch (e: Exception) {
format.removeKey(MediaFormat.KEY_PROFILE)
mediaCodec?.reset()
mediaCodec?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
}
val surface = mediaCodec?.createInputSurface() ?: throw Exception("Failed to create surface")
mediaCodec?.start()
Thread {
Thread.currentThread().priority = Thread.MAX_PRIORITY
streamLoop(ip, port)
}.start()
manager.openCamera(camId, object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
cameraDevice = camera
createSession(camera, surface)
}
override fun onDisconnected(camera: CameraDevice) { stopInternal() }
override fun onError(camera: CameraDevice, error: Int) {
onErrorCallback?.invoke("Camera Error: $error")
stopInternal()
}
}, camHandler)
} catch (e: Exception) {
Log.e(TAG, "Start Failed", e)
onErrorCallback?.invoke("Camera failed: ${e.message}")
stopInternal()
}
}
private fun createSession(camera: CameraDevice, surface: Surface) {
try {
camera.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onConfigured(session: CameraCaptureSession) {
if (!isStreaming) return
captureSession = session
try {
requestBuilder = camera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
addTarget(surface)
set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO)
set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(currentConfig.fps, currentConfig.fps))
set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF)
}
updateSession()
} catch (e: Exception) {}
}
override fun onConfigureFailed(session: CameraCaptureSession) {}
}, camHandler)
} catch (e: Exception) {}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun updateSession() {
val builder = requestBuilder ?: return
val session = captureSession ?: return
try {
if (zoomRatio > 1f) builder.set(CaptureRequest.CONTROL_ZOOM_RATIO, zoomRatio)
builder.set(CaptureRequest.FLASH_MODE, if (flashMode) CameraMetadata.FLASH_MODE_TORCH else CameraMetadata.FLASH_MODE_OFF)
builder.set(CaptureRequest.CONTROL_AWB_MODE, wbMode)
builder.set(CaptureRequest.CONTROL_AF_MODE, focusMode)
if (focusMode == CameraMetadata.CONTROL_AF_MODE_OFF) builder.set(CaptureRequest.LENS_FOCUS_DISTANCE, focusDistance)
builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureCompensation)
session.setRepeatingRequest(builder.build(), null, camHandler)
} catch (e: Exception) {}
}
private fun streamLoop(ip: InetAddress, port: Int) {
val bufferInfo = MediaCodec.BufferInfo()
val maxChunkSize = 1400
var lastUpdate = System.currentTimeMillis()
try {
while (isStreaming) {
val codec = mediaCodec ?: break
val index = try { codec.dequeueOutputBuffer(bufferInfo, 10000) } catch (e: Exception) { -1 }
if (index >= 0) {
val buffer = codec.getOutputBuffer(index)
if (buffer != null) {
buffer.position(bufferInfo.offset)
buffer.limit(bufferInfo.offset + bufferInfo.size)
val data = ByteArray(bufferInfo.size)
buffer.get(data)
var offset = 0
while (offset < data.size && isStreaming) {
val chunkSize = min(maxChunkSize, data.size - offset)
try { socket?.send(DatagramPacket(data, offset, chunkSize, ip, port)) } catch (e: Exception) {}
offset += chunkSize
}
val now = System.currentTimeMillis()
if (now - lastUpdate > 500) {
connectionManager?.updateActivity()
lastUpdate = now
}
}
codec.releaseOutputBuffer(index, false)
}
}
} catch (e: Exception) {}
}
fun stop() { camHandler.post { stopInternal(sendBye = true) } }
fun cleanup() { stop(); cameraThread.quitSafely() }
private fun stopInternal(sendBye: Boolean = true) {
if (sendBye && isStreaming) sendControl("BYE")
isStreaming = false
try { captureSession?.stopRepeating(); captureSession?.close() } catch (e: Exception) {}
captureSession = null
try { cameraDevice?.close() } catch (e: Exception) {}
cameraDevice = null
try { mediaCodec?.stop(); mediaCodec?.release() } catch (e: Exception) {}
mediaCodec = null
try { socket?.close() } catch (e: Exception) {}
socket = null
}
private fun getCameraId(front: Boolean): String {
val target = if (front) CameraCharacteristics.LENS_FACING_FRONT else CameraCharacteristics.LENS_FACING_BACK
return manager.cameraIdList.firstOrNull {
manager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == target
} ?: manager.cameraIdList[0]
}
}