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, val maxZoom: Float, val hasFlash: Boolean, val hasFrontCamera: Boolean, val supportedWhiteBalance: List, val focusRange: Range?, val exposureRange: Range? ) 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() // 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] } }