diff --git a/app/src/main/java/com/srtk/airlink/AudioStreamer.kt b/app/src/main/java/com/srtk/airlink/AudioStreamer.kt new file mode 100644 index 0000000..97a767e --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/AudioStreamer.kt @@ -0,0 +1,176 @@ +package com.srtk.airlink + +import android.Manifest +import android.content.Context +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import androidx.annotation.RequiresPermission +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +data class AudioQuality(val sampleRate: Int, val label: String) { + companion object { + val LOW = AudioQuality(16000, "Low (16 kHz)") + val MEDIUM = AudioQuality(24000, "Medium (24 kHz)") + val HIGH = AudioQuality(48000, "High (48 kHz)") + val ULTRA = AudioQuality(48000, "Ultra (48 kHz Stereo)") + + val ALL = listOf(LOW, MEDIUM, HIGH, ULTRA) + } + + val isUltra: Boolean get() = this == ULTRA +} + +class AudioStreamer(private val context: Context) { + private val TAG = "AudioStreamer" + + private val audioThread = HandlerThread("AudioThread").apply { start() } + private val audioHandler = Handler(audioThread.looper) + + private var audioRecord: AudioRecord? = null + private var socket: DatagramSocket? = null + + @Volatile private var isStreaming = false + private var currentQuality = AudioQuality.HIGH + + companion object { + private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT + private const val MAX_PACKET_SIZE = 1400 + } + + fun setQuality(quality: AudioQuality) { + currentQuality = quality + Log.d(TAG, "Audio quality set to: ${quality.label}") + } + + fun start(ip: InetAddress, port: Int) { + audioHandler.post @androidx.annotation.RequiresPermission(android.Manifest.permission.RECORD_AUDIO) { + try { + stopInternal() + Thread.sleep(200) + startInternal(ip, port) + } catch (e: Exception) { + Log.e(TAG, "Start error", e) + } + } + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + private fun startInternal(ip: InetAddress, port: Int) { + try { + socket = DatagramSocket() + + val channelConfig = if (currentQuality.isUltra) + AudioFormat.CHANNEL_IN_STEREO + else + AudioFormat.CHANNEL_IN_MONO + + val minBufferSize = AudioRecord.getMinBufferSize( + currentQuality.sampleRate, + channelConfig, + AUDIO_FORMAT + ) + + if (minBufferSize == AudioRecord.ERROR_BAD_VALUE || minBufferSize == AudioRecord.ERROR) { + Log.e(TAG, "Invalid buffer size for sample rate: ${currentQuality.sampleRate}") + return + } + + val bufferSize = minBufferSize * 4 + + audioRecord = AudioRecord( + MediaRecorder.AudioSource.MIC, + currentQuality.sampleRate, + channelConfig, + AUDIO_FORMAT, + bufferSize + ) + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "AudioRecord initialization failed") + return + } + + audioRecord?.startRecording() + isStreaming = true + + Log.d(TAG, "Audio streaming started: ${currentQuality.label}") + + Thread { + streamAudioLoop(ip, port, bufferSize) + }.start() + + } catch (e: Exception) { + Log.e(TAG, "Start failed: ${e.message}", e) + stopInternal() + } + } + + private fun streamAudioLoop(ip: InetAddress, port: Int, bufferSize: Int) { + val buffer = ByteArray(MAX_PACKET_SIZE) + var packetsDropped = 0 + var totalPackets = 0 + + try { + while (isStreaming) { + val record = audioRecord ?: break + + val readBytes = record.read(buffer, 0, buffer.size) + + if (readBytes > 0) { + try { + socket?.send( + DatagramPacket(buffer, readBytes, ip, port) + ) + totalPackets++ + } catch (e: Exception) { + packetsDropped++ + if (packetsDropped % 100 == 0) { + Log.w(TAG, "Dropped $packetsDropped/$totalPackets packets") + } + } + } else if (readBytes < 0) { + Log.e(TAG, "AudioRecord read error: $readBytes") + break + } + } + } catch (e: Exception) { + Log.e(TAG, "Audio stream loop error", e) + } + + Log.d(TAG, "Audio stream ended. Packets: $totalPackets, Dropped: $packetsDropped") + } + + fun stop() { + audioHandler.post { stopInternal() } + } + + fun cleanup() { + stop() + audioThread.quitSafely() + } + + private fun stopInternal() { + isStreaming = false + + try { + audioRecord?.stop() + audioRecord?.release() + audioRecord = null + } catch (e: Exception) { + Log.e(TAG, "AudioRecord stop error", e) + } + + try { + socket?.close() + socket = null + } catch (e: Exception) { + Log.e(TAG, "Socket close error", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/CameraStreamer.kt b/app/src/main/java/com/srtk/airlink/CameraStreamer.kt index 7c2e8d2..c0d15df 100644 --- a/app/src/main/java/com/srtk/airlink/CameraStreamer.kt +++ b/app/src/main/java/com/srtk/airlink/CameraStreamer.kt @@ -5,13 +5,18 @@ 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.Bundle +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 @@ -21,8 +26,20 @@ 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() = "${height}p (${width}×${height} @ ${fps}fps)" + fun toDetailedString() = "${width}×${height} @ ${fps}fps" } data class CameraCapabilities( @@ -44,7 +61,6 @@ enum class WhiteBalance(val value: Int, val label: String) { 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) @@ -54,10 +70,12 @@ class CameraStreamer(private val context: Context) { 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(960, 720, 30) - private var bitrate = 3000000 + private var currentConfig = VideoConfig(1280, 720, 30) private var zoomRatio = 1f private var flashMode = false @@ -65,24 +83,42 @@ class CameraStreamer(private val context: Context) { 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 onDisconnectedCallback: (() -> Unit)? = null + private var connectionManager: ConnectionManager? = null - private var lastPacketTime = 0L - private val connectionTimeout = 5000L // 5 seconds + fun setConnectionManager(manager: ConnectionManager) { connectionManager = manager } + fun setErrorCallback(callback: (String) -> Unit) { onErrorCallback = callback } - 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() } - fun setDisconnectedCallback(callback: () -> Unit) { - onDisconnectedCallback = callback - } + /** + * 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 + } - // Test if a resolution actually works with the encoder - private fun testEncoderResolution(width: Int, height: Int): Boolean { - var testCodec: MediaCodec? = null - try { val format = MediaFormat.createVideoFormat( MediaFormat.MIMETYPE_VIDEO_AVC, width, @@ -92,123 +128,142 @@ class CameraStreamer(private val context: Context) { MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface ) - setInteger(MediaFormat.KEY_BIT_RATE, 2000000) - setInteger(MediaFormat.KEY_FRAME_RATE, 30) - setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) + setInteger(MediaFormat.KEY_BIT_RATE, bitrate) + setInteger(MediaFormat.KEY_FRAME_RATE, fps) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) } - testCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) - testCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - testCodec.release() - - Log.d(TAG, "✅ Resolution ${width}x${height} WORKS") - return true + 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, "❌ Resolution ${width}x${height} FAILED: ${e.message}") - return false + Log.d(TAG, "✗ ${width}x${height} @ ${fps}fps - Encoder rejected: ${e.message}") + false } finally { try { - testCodec?.release() + codec?.release() } catch (e: Exception) {} } } - private fun is16by9(width: Int, height: Int): Boolean { - val ratio = width.toDouble() / height.toDouble() - return abs(ratio - 16.0 / 9.0) < 0.1 - } - - private fun is4by3(width: Int, height: Int): Boolean { - val ratio = width.toDouble() / height.toDouble() - return abs(ratio - 4.0 / 3.0) < 0.1 - } - - fun getCameraCapabilities(useFront: Boolean): CameraCapabilities { + 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)!! - val allSizes = map.getOutputSizes(MediaCodec::class.java) - .filter { is16by9(it.width, it.height) || is4by3(it.width, it.height) } - .sortedByDescending { it.width * it.height } + // 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 encoder capabilities...") + Log.d(TAG, "Testing ${cameraSizes.size} camera resolutions against encoder...") - val configs = mutableListOf() - val fpsRanges = chars.get( - CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES - ) ?: emptyArray() + val validConfigs = mutableListOf() - for (size in allSizes) { - try { - // TEST if encoder actually supports this resolution - if (!testEncoderResolution(size.width, size.height)) { - continue - } + // 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 +// ) - val minFrameDuration = map.getOutputMinFrameDuration( - MediaCodec::class.java, - size - ) + // 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") +// } +// } - if (minFrameDuration <= 0) continue + // 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 maxFpsFromDuration = (1_000_000_000.0 / minFrameDuration).toInt() - val validRanges = fpsRanges.filter { - it.upper <= maxFpsFromDuration && it.upper >= 15 - } + val ratio = size.width.toDouble() / size.height - val targetFps = min(validRanges.maxOfOrNull { it.upper } ?: 30, 30) + // 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 - if (targetFps >= 15) { - configs.add(VideoConfig(size.width, size.height, targetFps)) - } - } catch (e: Exception) { - Log.w(TAG, "Skipping ${size.width}x${size.height}: ${e.message}") + 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") } } - if (configs.isEmpty()) { - configs.add(VideoConfig(640, 480, 30)) - Log.w(TAG, "No configs found, added 640x480 fallback") + // 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 maxZoom = chars.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f - val hasFlash = chars.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true - val hasFront = manager.cameraIdList.any { - manager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == - CameraCharacteristics.LENS_FACING_FRONT + 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()}") } - val minFocus = chars.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE) - val exposureRange = chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE) - - return CameraCapabilities( - configs.sortedByDescending { it.width * it.height }, - maxZoom, - hasFlash, - hasFront, + 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), - if (minFocus != null && minFocus > 0) Range(0f, minFocus) else null, - exposureRange + chars.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE)?.let { + if (it > 0) Range(0f, it) else null + }, + chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE) ) } - fun start( - ip: InetAddress, - port: Int, - config: VideoConfig, - useFront: Boolean - ) { + @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(3, TimeUnit.SECONDS)) { + if (restartLock.tryAcquire(2500, TimeUnit.MILLISECONDS)) { try { - stopInternal() - Thread.sleep(500) + stopInternal(sendBye = false) + Thread.sleep(100) startInternal(ip, port, camId) } finally { restartLock.release() @@ -216,335 +271,197 @@ class CameraStreamer(private val context: Context) { } } catch (e: Exception) { Log.e(TAG, "Start error", e) - onErrorCallback?.invoke("Failed to start: ${e.message}") + onErrorCallback?.invoke("Start failed: ${e.message}") } } } - fun updateBitrate(mbps: Int) { - val newBitrate = mbps * 1_000_000 - if (bitrate == newBitrate) return - - bitrate = newBitrate - - camHandler.post { - try { - mediaCodec?.let { codec -> - val bundle = Bundle().apply { - putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bitrate) - } - codec.setParameters(bundle) - Log.d(TAG, "Bitrate updated to ${mbps} Mbps") - } - } catch (e: Exception) { - Log.e(TAG, "Bitrate update failed", e) - } - } - } - - fun setZoom(z: Float) { - zoomRatio = z - updateSession() - } - - fun setTorch(on: Boolean) { - flashMode = on - updateSession() - } - - fun setWhiteBalance(wb: WhiteBalance) { - wbMode = wb.value - updateSession() - } - + @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 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 - ) { + private fun startInternal(ip: InetAddress, port: Int, camId: String) { try { socket = DatagramSocket() isStreaming = true - lastPacketTime = System.currentTimeMillis() val width = currentConfig.width val height = currentConfig.height + val fps = currentConfig.fps - val pixelCount = width * height - val calculatedBitrate = when { - pixelCount >= 1920 * 1080 -> 8_000_000 - pixelCount >= 1280 * 720 -> 5_000_000 - pixelCount >= 640 * 480 -> 2_500_000 + val bitrate = when { + width * height >= 1920 * 1080 -> 4_000_000 + width * height >= 1280 * 720 -> 2_500_000 else -> 1_500_000 } - Log.d(TAG, "Starting: ${width}x${height} @ ${currentConfig.fps}fps") - - 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, calculatedBitrate) - setInteger(MediaFormat.KEY_FRAME_RATE, currentConfig.fps) - setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) + 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) - mediaCodec?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - - val surface = mediaCodec?.createInputSurface() - ?: throw Exception("Failed to create input surface") + 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() - Log.d(TAG, "✅ Encoder started with ${width}x${height}") - - // Start connection monitor - Thread { - while (isStreaming) { - Thread.sleep(1000) - if (System.currentTimeMillis() - lastPacketTime > connectionTimeout) { - Log.w(TAG, "Server disconnected - no data sent") - stop() - onDisconnectedCallback?.invoke() - break - } - } - }.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) - } + 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) - override fun onDisconnected(camera: CameraDevice) { - Log.w(TAG, "Camera disconnected") - stopInternal() - } - - override fun onError(camera: CameraDevice, error: Int) { - Log.e(TAG, "Camera error: $error") - stopInternal() - onErrorCallback?.invoke("Camera error: $error") - } - }, - camHandler - ) } catch (e: Exception) { - Log.e(TAG, "Start failed: ${e.message}", e) - onErrorCallback?.invoke("Failed to start: ${e.message}") + 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() { - override fun onConfigured(session: CameraCaptureSession) { - if (!isStreaming) return - - captureSession = session - requestBuilder = camera.createCaptureRequest( - CameraDevice.TEMPLATE_RECORD - ).apply { + 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_MODE, CameraMetadata.CONTROL_AE_MODE_ON) - set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, - Range(currentConfig.fps, currentConfig.fps)) - set(CaptureRequest.CONTROL_AF_MODE, focusMode) - set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, - CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_ON) - - if (focusMode == CameraMetadata.CONTROL_AF_MODE_OFF) { - set(CaptureRequest.LENS_FOCUS_DISTANCE, focusDistance) - } - set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureCompensation) + 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() - Log.d(TAG, "Camera session started") - } - - override fun onConfigureFailed(session: CameraCaptureSession) { - Log.e(TAG, "Session config failed") - onErrorCallback?.invoke("Camera session failed") - } - }, - camHandler - ) - } catch (e: Exception) { - Log.e(TAG, "Create session error", e) - } + } 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 - ) + 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) - } - + 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) { - Log.e(TAG, "Update session error", e) - } + } catch (e: Exception) {} } private fun streamLoop(ip: InetAddress, port: Int) { val bufferInfo = MediaCodec.BufferInfo() val maxChunkSize = 1400 - - Log.d(TAG, "Stream loop started") + var lastUpdate = System.currentTimeMillis() try { while (isStreaming) { val codec = mediaCodec ?: break + val index = try { codec.dequeueOutputBuffer(bufferInfo, 10000) } catch (e: Exception) { -1 } - val index = codec.dequeueOutputBuffer(bufferInfo, 10000) + 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) - when (index) { - MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - val format = codec.outputFormat - Log.d(TAG, "Output format: $format") - } + 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 + } - in 0..Int.MAX_VALUE -> { - try { - if (bufferInfo.size <= 0) { - codec.releaseOutputBuffer(index, false) - continue - } - - val buffer = codec.getOutputBuffer(index) ?: continue - buffer.position(bufferInfo.offset) - buffer.limit(bufferInfo.offset + bufferInfo.size) - - val frameData = ByteArray(bufferInfo.size) - buffer.get(frameData) - - var offset = 0 - while (offset < frameData.size && isStreaming) { - val chunkSize = min(maxChunkSize, frameData.size - offset) - socket?.send( - DatagramPacket(frameData, offset, chunkSize, ip, port) - ) - offset += chunkSize - } - - lastPacketTime = System.currentTimeMillis() - codec.releaseOutputBuffer(index, false) - } catch (e: Exception) { - Log.e(TAG, "Buffer processing error", e) + val now = System.currentTimeMillis() + if (now - lastUpdate > 500) { + connectionManager?.updateActivity() + lastUpdate = now } } + codec.releaseOutputBuffer(index, false) } } - } catch (e: Exception) { - Log.e(TAG, "Stream loop error", e) - } - - Log.d(TAG, "Stream loop ended") + } catch (e: Exception) {} } - fun stop() { - camHandler.post { stopInternal() } - } + fun stop() { camHandler.post { stopInternal(sendBye = true) } } + fun cleanup() { stop(); cameraThread.quitSafely() } - fun cleanup() { - stop() - cameraThread.quitSafely() - } + private fun stopInternal(sendBye: Boolean = true) { + if (sendBye && isStreaming) sendControl("BYE") - private fun stopInternal() { isStreaming = false - try { - captureSession?.stopRepeating() - captureSession?.close() - captureSession = null - } catch (e: Exception) { - Log.e(TAG, "Session stop error", e) - } - - try { - cameraDevice?.close() - cameraDevice = null - } catch (e: Exception) { - Log.e(TAG, "Camera close error", e) - } - - try { - mediaCodec?.stop() - mediaCodec?.release() - mediaCodec = null - } catch (e: Exception) { - Log.e(TAG, "Codec stop error", e) - } - - try { - socket?.close() - socket = null - } catch (e: Exception) { - Log.e(TAG, "Socket close error", e) - } - - requestBuilder = null + 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 + 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.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == target } ?: manager.cameraIdList[0] } } \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/ConnectionManager.kt b/app/src/main/java/com/srtk/airlink/ConnectionManager.kt new file mode 100644 index 0000000..934479d --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/ConnectionManager.kt @@ -0,0 +1,129 @@ +package com.srtk.airlink + +import android.util.Log +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +data class ConnectionState( + val isConnected: Boolean = false, + val error: String? = null +) + +class ConnectionManager { + private val TAG = "ConnectionManager" + private val _connectionState = MutableStateFlow(ConnectionState()) + val connectionState: StateFlow = _connectionState + + private var heartbeatSocket: DatagramSocket? = null + private var heartbeatJob: Job? = null + private var serverIp: InetAddress? = null + private var serverPort: Int = 0 + + @Volatile private var lastDataReceived = 0L + + /** + * Tries to ping the server. Returns true if server responds with "PONG". + */ + suspend fun handshake(ip: InetAddress, port: Int): Boolean = withContext(Dispatchers.IO) { + var socket: DatagramSocket? = null + try { + socket = DatagramSocket() + socket.soTimeout = 1000 // 1 second timeout + + val msg = "PING".toByteArray() + val packet = DatagramPacket(msg, msg.size, ip, port) + + // Try 2 times + for (i in 1..2) { + socket.send(packet) + val buffer = ByteArray(64) + val p = DatagramPacket(buffer, buffer.size) + try { + socket.receive(p) + val response = String(p.data, 0, p.length) + if (response.contains("PONG") || response.contains("ACK")) { + return@withContext true + } + } catch (e: Exception) { + // Timeout, retry + } + } + return@withContext false + } catch (e: Exception) { + return@withContext false + } finally { + socket?.close() + } + } + + fun startMonitoring(ip: InetAddress, port: Int) { + stopMonitoring() + + serverIp = ip + serverPort = port + lastDataReceived = System.currentTimeMillis() + + try { + heartbeatSocket = DatagramSocket() + heartbeatSocket?.soTimeout = 1000 + } catch (e: Exception) { + Log.e(TAG, "Socket error", e) + return + } + + _connectionState.value = ConnectionState(isConnected = true) + + heartbeatJob = CoroutineScope(Dispatchers.IO).launch { + val buffer = ByteArray(64) + val p = DatagramPacket(buffer, buffer.size) + + while (isActive) { + // 1. Send Heartbeat + try { + val msg = "HEARTBEAT".toByteArray() + heartbeatSocket?.send(DatagramPacket(msg, msg.size, serverIp, serverPort)) + } catch (e: Exception) {} + + // 2. Listen + try { + heartbeatSocket?.receive(p) + val response = String(p.data, 0, p.length) + if (response.contains("BYE")) { + withContext(Dispatchers.Main) { + _connectionState.value = ConnectionState(false, "Server stopped") + } + break + } + updateActivity() + } catch (e: Exception) { } // Timeout + + // 3. Timeout Check (5s) + if (System.currentTimeMillis() - lastDataReceived > 5000) { + withContext(Dispatchers.Main) { + _connectionState.value = ConnectionState(false, "Connection lost") + } + break + } + } + } + } + + fun updateActivity() { + lastDataReceived = System.currentTimeMillis() + } + + fun stopMonitoring() { + heartbeatJob?.cancel() + heartbeatSocket?.close() + heartbeatSocket = null + _connectionState.value = ConnectionState() + } + + fun reportError(error: String) { + _connectionState.value = ConnectionState(isConnected = false, error = error) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/MainActivity.kt b/app/src/main/java/com/srtk/airlink/MainActivity.kt index 4c3e402..3300b62 100644 --- a/app/src/main/java/com/srtk/airlink/MainActivity.kt +++ b/app/src/main/java/com/srtk/airlink/MainActivity.kt @@ -1,11 +1,14 @@ package com.srtk.airlink import android.Manifest +import android.annotation.SuppressLint +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.compose.animation.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -18,20 +21,22 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.net.InetAddress class MainActivity : ComponentActivity() { private lateinit var streamer: CameraStreamer + private lateinit var audioStreamer: AudioStreamer private lateinit var discovery: DiscoveryManager private val permissionLauncher = registerForActivityResult( @@ -40,9 +45,11 @@ class MainActivity : ComponentActivity() { if (!permissions.values.all { it }) finish() } + @RequiresApi(Build.VERSION_CODES.Q) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) streamer = CameraStreamer(this) + audioStreamer = AudioStreamer(this) discovery = DiscoveryManager(this) permissionLauncher.launch( @@ -54,7 +61,7 @@ class MainActivity : ComponentActivity() { setContent { AirLinkTheme { - AppNavigation(discovery, streamer) + AppNavigation(discovery, streamer, audioStreamer) } } } @@ -62,41 +69,41 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() streamer.cleanup() + audioStreamer.cleanup() } } -// Premium Blue-Cyan Color Palette matching the icon -private val DarkBg = Color(0xFF0A0E27) -private val SurfaceBg = Color(0xFF141B3C) -private val CardBg = Color(0xFF1C2447) -private val CardBgLight = Color(0xFF232D54) -private val AccentBlue = Color(0xFF5B9FE3) // Primary blue from icon -private val AccentCyan = Color(0xFF60D5DD) // Cyan from icon -private val AccentGreen = Color(0xFF34C759) // Success green -private val AccentRed = Color(0xFFFF3B30) // Error red -private val AccentOrange = Color(0xFFFF9500) // Warning orange -private val TextPrimary = Color(0xFFFFFFFF) -private val TextSecondary = Color(0xFF8E96B7) -private val DividerColor = Color(0xFF2A3558) -private val OverlayBg = Color(0xFF0F1429) +// iOS-Inspired OLED Theme +private val OLEDBlack = Color(0xFF000000) +private val DarkCard = Color(0xFF1C1C1E) +private val DarkCardElevated = Color(0xFF2C2C2E) +private val SystemBlue = Color(0xFF0A84FF) +private val SystemGreen = Color(0xFF32D74B) +private val SystemRed = Color(0xFFFF453A) +private val SystemOrange = Color(0xFFFF9F0A) +private val SystemTeal = Color(0xFF64D2FF) +private val SystemPurple = Color(0xFFBF5AF2) +private val LabelPrimary = Color(0xFFFFFFFF) +private val LabelSecondary = Color(0xFF8E8E93) +private val LabelTertiary = Color(0xFF48484A) +private val Separator = Color(0xFF38383A) @Composable fun AirLinkTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = darkColorScheme( - background = DarkBg, - surface = SurfaceBg, - primary = AccentBlue, - secondary = AccentCyan, - tertiary = AccentGreen, - error = AccentRed + background = OLEDBlack, + surface = DarkCard, + primary = SystemBlue, + secondary = SystemTeal, + tertiary = SystemGreen, + error = SystemRed ), typography = Typography( displayLarge = MaterialTheme.typography.displayLarge.copy(fontWeight = FontWeight.Bold), - displayMedium = MaterialTheme.typography.displayMedium.copy(fontWeight = FontWeight.Bold), - headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold), + displayMedium = MaterialTheme.typography.displayMedium.copy(fontWeight = FontWeight.SemiBold), titleLarge = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - titleMedium = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + titleMedium = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium), bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Normal), ), content = content @@ -105,15 +112,19 @@ fun AirLinkTheme(content: @Composable () -> Unit) { enum class Screen { SCANNING, DASHBOARD } +@RequiresApi(Build.VERSION_CODES.Q) @OptIn(ExperimentalAnimationApi::class) @Composable -fun AppNavigation(discovery: DiscoveryManager, streamer: CameraStreamer) { +fun AppNavigation(discovery: DiscoveryManager, streamer: CameraStreamer, audioStreamer: AudioStreamer) { var currentScreen by remember { mutableStateOf(Screen.SCANNING) } var selectedServer by remember { mutableStateOf(null) } var isStreaming by remember { mutableStateOf(false) } BackHandler(enabled = currentScreen == Screen.DASHBOARD) { - if (isStreaming) streamer.stop() + if (isStreaming) { + streamer.stop() + audioStreamer.stop() + } isStreaming = false currentScreen = Screen.SCANNING } @@ -137,11 +148,15 @@ fun AppNavigation(discovery: DiscoveryManager, streamer: CameraStreamer) { Screen.DASHBOARD -> DashboardScreen( streamer = streamer, + audioStreamer = audioStreamer, server = selectedServer!!, isStreaming = isStreaming, onStreamingChange = { isStreaming = it }, onBack = { - if (isStreaming) streamer.stop() + if (isStreaming) { + streamer.stop() + audioStreamer.stop() + } isStreaming = false currentScreen = Screen.SCANNING } @@ -158,20 +173,35 @@ fun ScanScreen( ) { var servers by remember { mutableStateOf(listOf()) } var showManualDialog by remember { mutableStateOf(false) } + var serverLastSeen by remember { mutableStateOf(mapOf()) } + + LaunchedEffect(Unit) { + while (true) { + delay(500) + val now = System.currentTimeMillis() + val timeout = 15000L + servers = servers.filter { server -> + val lastSeen = serverLastSeen[server.name] ?: 0 + (now - lastSeen) < timeout + } + } + } LaunchedEffect(Unit) { discovery.discover().collect { server -> - if (!servers.contains(server)) { + val now = System.currentTimeMillis() + serverLastSeen = serverLastSeen + (server.name to now) + if (!servers.any { it.name == server.name }) { servers = servers + server } } } Scaffold( - containerColor = DarkBg, + containerColor = OLEDBlack, topBar = { Surface( - color = SurfaceBg.copy(alpha = 0.95f), + color = OLEDBlack, modifier = Modifier.statusBarsPadding() ) { Row( @@ -180,49 +210,47 @@ fun ScanScreen( .padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - // App Icon Box( modifier = Modifier - .size(44.dp) - .background(AccentBlue.copy(alpha = 0.2f), CircleShape), + .size(48.dp) + .background(SystemBlue.copy(alpha = 0.15f), CircleShape), contentAlignment = Alignment.Center ) { Icon( - Icons.Filled.CameraAlt, + Icons.Filled.Videocam, contentDescription = null, - tint = AccentBlue, - modifier = Modifier.size(24.dp) + tint = SystemBlue, + modifier = Modifier.size(26.dp) ) } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { Text( "AirLink", - fontSize = 22.sp, + fontSize = 24.sp, fontWeight = FontWeight.Bold, - color = TextPrimary + color = LabelPrimary ) Text( "Wireless Camera", - fontSize = 13.sp, - color = TextSecondary + fontSize = 14.sp, + color = LabelSecondary ) } - // Manual connect button IconButton( onClick = { showManualDialog = true }, modifier = Modifier - .size(44.dp) - .background(CardBg, CircleShape) + .size(48.dp) + .background(DarkCard, CircleShape) ) { Icon( Icons.Outlined.Add, "Manual IP", - tint = AccentCyan, - modifier = Modifier.size(22.dp) + tint = SystemBlue, + modifier = Modifier.size(24.dp) ) } } @@ -235,7 +263,6 @@ fun ScanScreen( .fillMaxSize() ) { if (servers.isEmpty()) { - // Empty State - Scanning Column( modifier = Modifier .fillMaxSize() @@ -243,18 +270,16 @@ fun ScanScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - // Animated scanning indicator Box( modifier = Modifier .size(120.dp) - .background(CardBg, CircleShape) - .border(2.dp, AccentBlue.copy(alpha = 0.3f), CircleShape), + .background(DarkCard, CircleShape), contentAlignment = Alignment.Center ) { CircularProgressIndicator( - color = AccentBlue, + color = SystemBlue, strokeWidth = 3.dp, - modifier = Modifier.size(80.dp) + modifier = Modifier.size(70.dp) ) } @@ -262,9 +287,9 @@ fun ScanScreen( Text( "Scanning Network", - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = TextPrimary + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = LabelPrimary ) Spacer(modifier = Modifier.height(8.dp)) @@ -272,74 +297,69 @@ fun ScanScreen( Text( "Looking for available devices...", fontSize = 15.sp, - color = TextSecondary, + color = LabelSecondary, textAlign = androidx.compose.ui.text.style.TextAlign.Center ) Spacer(modifier = Modifier.height(40.dp)) - // Manual connect card - PremiumCard( - onClick = { showManualDialog = true } - ) { + Card(onClick = { showManualDialog = true }) { Row( - modifier = Modifier.padding(20.dp), + modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier - .size(48.dp) - .background(AccentCyan.copy(alpha = 0.15f), CircleShape), + .size(40.dp) + .background(SystemBlue.copy(alpha = 0.15f), CircleShape), contentAlignment = Alignment.Center ) { Icon( Icons.Outlined.Edit, null, - tint = AccentCyan, - modifier = Modifier.size(24.dp) + tint = SystemBlue, + modifier = Modifier.size(22.dp) ) } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( "Manual Connection", - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Medium, fontSize = 16.sp, - color = TextPrimary + color = LabelPrimary ) - Spacer(modifier = Modifier.height(4.dp)) Text( "Enter IP address", fontSize = 13.sp, - color = TextSecondary + color = LabelSecondary ) } Icon( Icons.Filled.ChevronRight, null, - tint = TextSecondary + tint = LabelTertiary, + modifier = Modifier.size(20.dp) ) } } } } else { - // Servers List Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp, vertical = 16.dp) ) { - // Section Header Text( "AVAILABLE DEVICES", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = TextSecondary, - letterSpacing = 1.sp + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = LabelSecondary, + letterSpacing = 0.5.sp ) Spacer(modifier = Modifier.height(12.dp)) @@ -349,48 +369,45 @@ fun ScanScreen( Spacer(modifier = Modifier.height(12.dp)) } - // Manual connect option - PremiumCard( - onClick = { showManualDialog = true }, - backgroundColor = CardBgLight - ) { + Card(onClick = { showManualDialog = true }) { Row( - modifier = Modifier.padding(20.dp), + modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier - .size(48.dp) - .background(AccentCyan.copy(alpha = 0.15f), CircleShape), + .size(40.dp) + .background(SystemBlue.copy(alpha = 0.15f), CircleShape), contentAlignment = Alignment.Center ) { Icon( Icons.Outlined.Add, null, - tint = AccentCyan, - modifier = Modifier.size(24.dp) + tint = SystemBlue, + modifier = Modifier.size(22.dp) ) } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(12.dp)) Text( "Add Manual Connection", fontWeight = FontWeight.Medium, fontSize = 16.sp, - color = AccentCyan, + color = SystemBlue, modifier = Modifier.weight(1f) ) Icon( Icons.Filled.ChevronRight, null, - tint = AccentCyan.copy(alpha = 0.5f) + tint = SystemBlue.copy(alpha = 0.5f), + modifier = Modifier.size(20.dp) ) } } - Spacer(modifier = Modifier.height(80.dp)) + Spacer(modifier = Modifier.height(100.dp)) } } } @@ -405,26 +422,23 @@ fun ScanScreen( val server = ServerInfo("Manual: $ip", addr, port) onServerSelected(server) showManualDialog = false - } catch (e: Exception) { - // Show error - } + } catch (e: Exception) {} } ) } } @Composable -fun PremiumCard( +fun Card( onClick: () -> Unit, - backgroundColor: Color = CardBg, content: @Composable () -> Unit ) { Card( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick), - colors = CardDefaults.cardColors(containerColor = backgroundColor), - shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = DarkCard), + shape = RoundedCornerShape(14.dp), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { content() @@ -433,47 +447,46 @@ fun PremiumCard( @Composable fun ServerCard(server: ServerInfo, onClick: () -> Unit) { - PremiumCard(onClick = onClick) { + Card(onClick = onClick) { Row( - modifier = Modifier.padding(20.dp), + modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - // Device Icon with pulse animation Box( modifier = Modifier - .size(56.dp) - .background(AccentBlue.copy(alpha = 0.15f), CircleShape), + .size(48.dp) + .background(SystemBlue.copy(alpha = 0.15f), CircleShape), contentAlignment = Alignment.Center ) { Icon( Icons.Filled.Laptop, null, - tint = AccentBlue, - modifier = Modifier.size(28.dp) + tint = SystemBlue, + modifier = Modifier.size(26.dp) ) } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { Text( server.name, - fontWeight = FontWeight.SemiBold, - color = TextPrimary, + fontWeight = FontWeight.Medium, + color = LabelPrimary, fontSize = 17.sp ) - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier - .size(6.dp) - .background(AccentGreen, CircleShape) + .size(8.dp) + .background(SystemGreen, CircleShape) ) Spacer(modifier = Modifier.width(6.dp)) Text( server.ip.hostAddress ?: "", fontSize = 14.sp, - color = TextSecondary + color = LabelSecondary ) } } @@ -481,7 +494,8 @@ fun ServerCard(server: ServerInfo, onClick: () -> Unit) { Icon( Icons.Filled.ChevronRight, null, - tint = TextSecondary + tint = LabelTertiary, + modifier = Modifier.size(20.dp) ) } } @@ -507,78 +521,50 @@ fun ManualIPDialog( enabled = ipText.isNotBlank(), modifier = Modifier .fillMaxWidth() - .height(48.dp), + .height(50.dp), colors = ButtonDefaults.buttonColors( - containerColor = AccentBlue, - disabledContainerColor = AccentBlue.copy(alpha = 0.3f) + containerColor = SystemBlue, + disabledContainerColor = SystemBlue.copy(alpha = 0.3f) ), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(14.dp) ) { - Text("Connect", fontWeight = FontWeight.Bold, color = Color.White) + Text("Connect", fontWeight = FontWeight.SemiBold, fontSize = 17.sp) } }, dismissButton = { - OutlinedButton( + TextButton( onClick = onDismiss, modifier = Modifier .fillMaxWidth() - .height(48.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = TextSecondary - ), - border = BorderStroke(1.dp, DividerColor), - shape = RoundedCornerShape(12.dp) + .height(50.dp) ) { - Text("Cancel", fontWeight = FontWeight.Medium) + Text("Cancel", color = SystemBlue, fontWeight = FontWeight.Medium, fontSize = 17.sp) } }, title = { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(44.dp) - .background(AccentCyan.copy(alpha = 0.15f), CircleShape), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Outlined.Edit, - null, - tint = AccentCyan, - modifier = Modifier.size(22.dp) - ) - } - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - "Manual Connection", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = TextPrimary - ) - } + Text( + "Manual Connection", + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = LabelPrimary + ) }, text = { - Column( - modifier = Modifier.fillMaxWidth() - ) { + Column { Text( - "Connect to a device over the internet or VPN", - color = TextSecondary, + "Enter the IP address and port of your device", + color = LabelSecondary, fontSize = 14.sp ) Spacer(modifier = Modifier.height(24.dp)) - // IP Address Text( "IP ADDRESS", - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = TextSecondary, - letterSpacing = 1.sp + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = LabelSecondary, + letterSpacing = 0.5.sp ) Spacer(modifier = Modifier.height(8.dp)) @@ -586,16 +572,16 @@ fun ManualIPDialog( OutlinedTextField( value = ipText, onValueChange = { ipText = it }, - placeholder = { Text("192.168.1.100", color = TextSecondary.copy(alpha = 0.5f)) }, + placeholder = { Text("192.168.1.100", color = LabelTertiary) }, singleLine = true, colors = TextFieldDefaults.colors( - focusedContainerColor = CardBg, - unfocusedContainerColor = CardBg, - focusedTextColor = TextPrimary, - unfocusedTextColor = TextPrimary, - focusedIndicatorColor = AccentBlue, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = AccentBlue + focusedContainerColor = DarkCardElevated, + unfocusedContainerColor = DarkCardElevated, + focusedTextColor = LabelPrimary, + unfocusedTextColor = LabelPrimary, + focusedIndicatorColor = SystemBlue, + unfocusedIndicatorColor = Separator, + cursorColor = SystemBlue ), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth() @@ -603,13 +589,12 @@ fun ManualIPDialog( Spacer(modifier = Modifier.height(16.dp)) - // Port Text( "PORT", - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = TextSecondary, - letterSpacing = 1.sp + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = LabelSecondary, + letterSpacing = 0.5.sp ) Spacer(modifier = Modifier.height(8.dp)) @@ -617,35 +602,35 @@ fun ManualIPDialog( OutlinedTextField( value = portText, onValueChange = { portText = it }, - placeholder = { Text("5000", color = TextSecondary.copy(alpha = 0.5f)) }, + placeholder = { Text("5000", color = LabelTertiary) }, singleLine = true, colors = TextFieldDefaults.colors( - focusedContainerColor = CardBg, - unfocusedContainerColor = CardBg, - focusedTextColor = TextPrimary, - unfocusedTextColor = TextPrimary, - focusedIndicatorColor = AccentBlue, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = AccentBlue + focusedContainerColor = DarkCardElevated, + unfocusedContainerColor = DarkCardElevated, + focusedTextColor = LabelPrimary, + unfocusedTextColor = LabelPrimary, + focusedIndicatorColor = SystemBlue, + unfocusedIndicatorColor = Separator, + cursorColor = SystemBlue ), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth() ) } }, - shape = RoundedCornerShape(24.dp), - containerColor = OverlayBg, - iconContentColor = AccentCyan, - titleContentColor = TextPrimary, - textContentColor = TextSecondary, - tonalElevation = 0.dp + shape = RoundedCornerShape(18.dp), + containerColor = DarkCard, + titleContentColor = LabelPrimary, + textContentColor = LabelSecondary ) } +@RequiresApi(Build.VERSION_CODES.Q) @OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardScreen( streamer: CameraStreamer, + audioStreamer: AudioStreamer, server: ServerInfo, isStreaming: Boolean, onStreamingChange: (Boolean) -> Unit, @@ -654,55 +639,62 @@ fun DashboardScreen( var isFrontCamera by remember { mutableStateOf(false) } var capabilities by remember { mutableStateOf(null) } var selectedConfig by remember { mutableStateOf(null) } + + var isCameraEnabled by remember { mutableStateOf(true) } + var isAudioEnabled by remember { mutableStateOf(true) } + var wbMode by remember { mutableStateOf(WhiteBalance.AUTO) } var isFlashOn by remember { mutableStateOf(false) } var zoom by remember { mutableStateOf(1f) } - var focusMode by remember { mutableStateOf("Auto") } - var focusDist by remember { mutableStateOf(0f) } - var exposureComp by remember { mutableStateOf(0) } var errorMessage by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - streamer.setErrorCallback { error -> - errorMessage = error - if (isStreaming) { - onStreamingChange(false) - } - } - - streamer.setDisconnectedCallback { - errorMessage = "Server disconnected" - onStreamingChange(false) - onBack() - } - } + val connectionManager = remember { ConnectionManager() } + val connectionState by connectionManager.connectionState.collectAsState() LaunchedEffect(isFrontCamera) { - try { - val caps = streamer.getCameraCapabilities(isFrontCamera) - capabilities = caps - selectedConfig = caps.supportedConfigs.firstOrNull() + capabilities = null + val caps = streamer.getCameraCapabilities(isFrontCamera) + capabilities = caps + selectedConfig = caps.supportedConfigs.firstOrNull() - if (isStreaming && selectedConfig != null) { + if (isStreaming && isCameraEnabled && selectedConfig != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { streamer.start(server.ip, server.port, selectedConfig!!, isFrontCamera) } - } catch (e: Exception) { - errorMessage = "Camera initialization failed" } } - LaunchedEffect(errorMessage) { - if (errorMessage != null) { - delay(5000) - errorMessage = null + LaunchedEffect(Unit) { + streamer.setConnectionManager(connectionManager) + streamer.setErrorCallback { error -> + errorMessage = error + connectionManager.reportError(error) } } + LaunchedEffect(connectionState) { + if (!connectionState.isConnected && connectionState.error != null) { + if (isStreaming) { + errorMessage = connectionState.error + streamer.stop() + audioStreamer.stop() + onStreamingChange(false) + delay(1500) + onBack() + } + } + } + + LaunchedEffect(isStreaming) { + if (isStreaming) connectionManager.startMonitoring(server.ip, server.port) + else connectionManager.stopMonitoring() + } + Scaffold( - containerColor = DarkBg, + containerColor = OLEDBlack, topBar = { Surface( - color = SurfaceBg.copy(alpha = 0.95f), + color = OLEDBlack, modifier = Modifier.statusBarsPadding() ) { Row( @@ -715,12 +707,12 @@ fun DashboardScreen( onClick = onBack, modifier = Modifier .size(44.dp) - .background(CardBg, CircleShape) + .background(DarkCard, CircleShape) ) { Icon( Icons.Filled.ArrowBack, null, - tint = TextPrimary, + tint = SystemBlue, modifier = Modifier.size(20.dp) ) } @@ -730,27 +722,24 @@ fun DashboardScreen( Column(modifier = Modifier.weight(1f)) { Text( server.name, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.SemiBold, fontSize = 18.sp, - color = TextPrimary + color = LabelPrimary ) - Spacer(modifier = Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(8.dp) .background( - if (isStreaming) AccentGreen else AccentRed, + if (isStreaming) SystemGreen else LabelTertiary, CircleShape ) ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(6.dp)) Text( - if (isStreaming) "STREAMING" else "OFFLINE", + if (isStreaming) "Connected" else "Ready", fontSize = 13.sp, - color = if (isStreaming) AccentGreen else TextSecondary, - fontWeight = FontWeight.Medium, - letterSpacing = 0.5.sp + color = LabelSecondary ) } } @@ -760,12 +749,12 @@ fun DashboardScreen( onClick = { isFrontCamera = !isFrontCamera }, modifier = Modifier .size(44.dp) - .background(CardBg, CircleShape) + .background(DarkCard, CircleShape) ) { Icon( Icons.Filled.Cameraswitch, - contentDescription = "Flip Camera", - tint = AccentCyan, + "Flip", + tint = SystemBlue, modifier = Modifier.size(20.dp) ) } @@ -775,28 +764,23 @@ fun DashboardScreen( }, bottomBar = { Surface( - color = SurfaceBg.copy(alpha = 0.95f), + color = OLEDBlack, modifier = Modifier.navigationBarsPadding() ) { Column { AnimatedVisibility(visible = errorMessage != null) { - Surface( - color = AccentRed.copy(alpha = 0.15f), - modifier = Modifier.fillMaxWidth() + Box( + modifier = Modifier + .fillMaxWidth() + .background(SystemRed.copy(alpha = 0.15f)) + .padding(12.dp) ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Filled.Error, - null, - tint = AccentRed, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text(errorMessage ?: "", color = AccentRed, fontSize = 14.sp) - } + Text( + errorMessage ?: "", + color = SystemRed, + fontSize = 14.sp, + modifier = Modifier.align(Alignment.Center) + ) } } @@ -805,41 +789,46 @@ fun DashboardScreen( onClick = { if (isStreaming) { streamer.stop() + audioStreamer.stop() onStreamingChange(false) } else { selectedConfig?.let { config -> - try { - streamer.start(server.ip, server.port, config, isFrontCamera) - onStreamingChange(true) - errorMessage = null - } catch (e: Exception) { - errorMessage = "Failed to start stream" + CoroutineScope(Dispatchers.Main).launch { + val isAlive = connectionManager.handshake(server.ip, server.port) + if (isAlive) { + if (isCameraEnabled) streamer.start(server.ip, server.port, config, isFrontCamera) + if (isAudioEnabled) audioStreamer.start(server.ip, server.port + 1) + onStreamingChange(true) + errorMessage = null + } else { + errorMessage = "Server not responding" + } } } } }, modifier = Modifier .fillMaxWidth() - .height(56.dp), + .height(54.dp), colors = ButtonDefaults.buttonColors( - containerColor = if (isStreaming) AccentRed else AccentBlue + containerColor = if (isStreaming) SystemRed else SystemBlue, + contentColor = Color.White ), - shape = RoundedCornerShape(16.dp), - enabled = selectedConfig != null + shape = RoundedCornerShape(14.dp), + enabled = capabilities != null ) { Icon( - if (isStreaming) Icons.Filled.Stop else Icons.Filled.PlayArrow, + if (isStreaming) Icons.Filled.Stop else Icons.Filled.Wifi, null, - modifier = Modifier.size(22.dp), - tint = Color.White + tint = Color.White, + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - if (isStreaming) "STOP STREAM" else "START STREAM", - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - color = Color.White, - letterSpacing = 0.5.sp + if (isStreaming) "Disconnect" else "Connect", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White ) } } @@ -847,437 +836,446 @@ fun DashboardScreen( } } ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Resolution - CompactDropdown( - icon = Icons.Outlined.Videocam, - label = "Resolution", - currentValue = selectedConfig?.toDetailedString() ?: "Loading...", - items = capabilities?.supportedConfigs ?: emptyList(), - onItemSelected = { - selectedConfig = it - if (isStreaming) { - streamer.start(server.ip, server.port, it, isFrontCamera) + if (capabilities == null) { + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(color = SystemBlue, strokeWidth = 3.dp) + Spacer(modifier = Modifier.height(16.dp)) + Text("Loading Camera...", color = LabelSecondary, fontSize = 15.sp) + } + } else { + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + // VIDEO SECTION + SectionHeader("Video Settings", Icons.Filled.Videocam) + Spacer(modifier = Modifier.height(12.dp)) + + SettingsGroup { + Setting( + icon = Icons.Filled.Videocam, + label = "Camera", + value = if (isCameraEnabled) "On" else "Off", + valueColor = if (isCameraEnabled) SystemGreen else LabelSecondary + ) { + Switch( + checked = isCameraEnabled, + onCheckedChange = { enabled -> + isCameraEnabled = enabled + if (isStreaming) { + if (enabled) { + streamer.start(server.ip, server.port, selectedConfig!!, isFrontCamera) + } else { + streamer.stopCapture() + } + } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = SystemGreen, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = LabelTertiary + ) + ) } - }, - itemLabel = { it.toDetailedString() } - ) - // White Balance - CompactDropdown( - icon = Icons.Outlined.WbSunny, - label = "White Balance", - currentValue = wbMode.label, - items = WhiteBalance.values().toList(), - onItemSelected = { - wbMode = it - streamer.setWhiteBalance(it) - }, - itemLabel = { it.label } - ) + SettingDivider() - // Focus - if (capabilities?.focusRange != null) { - val focusModes = listOf("Auto", "Manual") - CompactDropdown( - icon = Icons.Outlined.CenterFocusStrong, - label = "Focus", - currentValue = if (focusMode == "Manual") "Manual (${String.format("%.1f", focusDist)})" else focusMode, - items = focusModes, - onItemSelected = { - focusMode = it - streamer.setFocusMode(it == "Auto") - }, - itemLabel = { it }, - expandedContent = if (focusMode == "Manual") { - { - Slider( - value = focusDist, - onValueChange = { - focusDist = it - streamer.setFocusDistance(it) + Dropdown( + icon = Icons.Outlined.HighQuality, + label = "Resolution", + currentValue = selectedConfig?.toDetailedString() ?: "None", + items = capabilities?.supportedConfigs ?: emptyList(), + onItemSelected = { + selectedConfig = it + if (isStreaming && isCameraEnabled) { + streamer.start(server.ip, server.port, it, isFrontCamera) + } + }, + itemLabel = { it.toDetailedString() } + ) + + if ((capabilities?.maxZoom ?: 1f) > 1f) { + SettingDivider() + ZoomSetting( + value = zoom, + maxZoom = capabilities?.maxZoom ?: 1f, + onValueChange = { zoom = it; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + streamer.setZoom(it) + } + } + ) + } + + if (capabilities?.hasFlash == true && !isFrontCamera) { + SettingDivider() + Setting( + icon = Icons.Filled.FlashOn, + label = "Flash", + value = if (isFlashOn) "On" else "Off", + valueColor = if (isFlashOn) SystemOrange else LabelSecondary + ) { + Switch( + checked = isFlashOn, + onCheckedChange = { isFlashOn = it; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + streamer.setTorch(it) + } }, - valueRange = 0f..(capabilities?.focusRange?.upper ?: 1f), - colors = SliderDefaults.colors( - thumbColor = AccentCyan, - activeTrackColor = AccentCyan, - inactiveTrackColor = DividerColor - ), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = SystemOrange, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = LabelTertiary + ) ) } - } else null - ) - } + } - // Exposure - if (capabilities?.exposureRange != null) { - CompactSlider( - icon = Icons.Outlined.Brightness6, - label = "Exposure", - value = exposureComp.toFloat(), - displayValue = if (exposureComp == 0) "Auto" else exposureComp.toString(), - onValueChange = { - exposureComp = it.toInt() - streamer.setExposure(exposureComp) - }, - valueRange = (capabilities?.exposureRange?.lower ?: -12).toFloat()..(capabilities?.exposureRange?.upper ?: 12).toFloat(), - accentColor = AccentOrange - ) - } + SettingDivider() - // Zoom - if ((capabilities?.maxZoom ?: 1f) > 1f) { - CompactSlider( - icon = Icons.Outlined.ZoomIn, - label = "Zoom", - value = zoom, - displayValue = "${String.format("%.1f", zoom)}×", - onValueChange = { - zoom = it - streamer.setZoom(it) - }, - valueRange = 1f..(capabilities?.maxZoom ?: 1f), - accentColor = AccentGreen - ) - } - - // Flash - if (capabilities?.hasFlash == true && !isFrontCamera) { - CompactToggle( - icon = Icons.Filled.FlashOn, - label = "Flash", - checked = isFlashOn, - onCheckedChange = { - isFlashOn = it - streamer.setTorch(it) - }, - accentColor = AccentOrange - ) - } - - Spacer(modifier = Modifier.height(40.dp)) - } - } -} - -@Composable -fun CompactDropdown( - icon: ImageVector, - label: String, - currentValue: String, - items: List, - onItemSelected: (T) -> Unit, - itemLabel: (T) -> String, - expandedContent: (@Composable () -> Unit)? = null -) { - var expanded by remember { mutableStateOf(false) } - - Card( - colors = CardDefaults.cardColors(containerColor = CardBg), - shape = RoundedCornerShape(16.dp) - ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = true } - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - icon, - null, - tint = AccentBlue, - modifier = Modifier.size(20.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - label, - fontSize = 13.sp, - color = TextSecondary - ) - Text( - currentValue, - color = TextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.Medium + Dropdown( + icon = Icons.Outlined.WbSunny, + label = "White Balance", + currentValue = wbMode.label, + items = WhiteBalance.values().toList(), + onItemSelected = { wbMode = it; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + streamer.setWhiteBalance(it) + } + }, + itemLabel = { it.label } ) } - Icon( - Icons.Filled.ArrowDropDown, - null, - tint = TextSecondary, - modifier = Modifier - .size(20.dp) - .rotate(if (expanded) 180f else 0f) - ) - } + Spacer(modifier = Modifier.height(24.dp)) - expandedContent?.invoke() - } + // MICROPHONE SECTION + SectionHeader("Microphone Settings", Icons.Filled.Mic) + Spacer(modifier = Modifier.height(12.dp)) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier - .background(CardBgLight) - .widthIn(min = 200.dp) - ) { - items.forEach { item -> - DropdownMenuItem( - text = { - Text( - itemLabel(item), - color = TextPrimary, - fontSize = 15.sp + SettingsGroup { + Setting( + icon = if (isAudioEnabled) Icons.Filled.Mic else Icons.Filled.MicOff, + label = "Microphone", + value = if (isAudioEnabled) "On" else "Off", + valueColor = if (isAudioEnabled) SystemGreen else LabelSecondary + ) { + Switch( + checked = isAudioEnabled, + onCheckedChange = { + isAudioEnabled = it + if (isStreaming) { + if (it) audioStreamer.start(server.ip, server.port + 1) + else audioStreamer.stop() + } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = SystemTeal, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = LabelTertiary + ) ) - }, - onClick = { - onItemSelected(item) - expanded = false } - ) + } + + Spacer(modifier = Modifier.height(100.dp)) } } } } @Composable -fun CompactSlider( - icon: ImageVector, - label: String, - value: Float, - displayValue: String, - onValueChange: (Float) -> Unit, - valueRange: ClosedFloatingPointRange, - accentColor: Color = AccentBlue -) { - Card( - colors = CardDefaults.cardColors(containerColor = CardBg), - shape = RoundedCornerShape(16.dp) +fun SectionHeader(title: String, icon: ImageVector) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp) ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - icon, - null, - tint = accentColor, - modifier = Modifier.size(20.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - label, - fontSize = 13.sp, - color = TextSecondary, - modifier = Modifier.weight(1f) - ) - - Text( - displayValue, - color = TextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Slider( - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - colors = SliderDefaults.colors( - thumbColor = accentColor, - activeTrackColor = accentColor, - inactiveTrackColor = DividerColor - ) - ) - } + Icon( + icon, + null, + tint = SystemBlue, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + title, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = LabelPrimary + ) } } @Composable -fun CompactToggle( - icon: ImageVector, - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - accentColor: Color = AccentBlue -) { +fun SettingsGroup(content: @Composable () -> Unit) { Card( - colors = CardDefaults.cardColors(containerColor = CardBg), - shape = RoundedCornerShape(16.dp) + colors = CardDefaults.cardColors(containerColor = DarkCard), + shape = RoundedCornerShape(14.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier.fillMaxWidth() ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - icon, - null, - tint = if (checked) accentColor else TextSecondary, - modifier = Modifier.size(20.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - label, - fontSize = 15.sp, - color = TextPrimary, - modifier = Modifier.weight(1f) - ) - - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = accentColor, - uncheckedThumbColor = Color.White, - uncheckedTrackColor = DividerColor - ) - ) - } + content() } } @Composable -fun ControlHeader( +fun SettingDivider() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .padding(start = 56.dp) + .background(Separator) + ) +} + +@Composable +fun Setting( icon: ImageVector, - title: String, - subtitle: String? = null, - tint: Color = AccentBlue + label: String, + value: String, + valueColor: Color = LabelSecondary, + trailing: @Composable () -> Unit ) { Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( icon, null, - tint = tint, - modifier = Modifier.size(20.dp) + tint = SystemBlue, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + label, + fontSize = 17.sp, + color = LabelPrimary, + modifier = Modifier.weight(1f) + ) + trailing() + } +} + +@SuppressLint("DefaultLocale") +@Composable +fun ZoomSetting( + value: Float, + maxZoom: Float, + onValueChange: (Float) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.ZoomIn, + null, + tint = SystemBlue, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Zoom", + fontSize = 17.sp, + color = LabelPrimary + ) + Spacer(modifier = Modifier.width(16.dp)) + Slider( + value = value, + onValueChange = onValueChange, + valueRange = 1f..maxZoom, + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = SystemBlue, + inactiveTrackColor = LabelTertiary + ), + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "${String.format("%.1f", value)}×", + fontSize = 16.sp, + color = LabelSecondary, + fontWeight = FontWeight.Medium, + modifier = Modifier.width(42.dp) ) - Spacer(modifier = Modifier.width(10.dp)) - Column { - Text( - title, - color = TextPrimary, - fontWeight = FontWeight.SemiBold, - fontSize = 15.sp - ) - if (subtitle != null) { - Text( - subtitle, - color = TextSecondary, - fontSize = 13.sp - ) - } - } } } @Composable -fun PremiumDropdown( +fun Dropdown( + icon: ImageVector, label: String, currentValue: String, - icon: ImageVector, items: List, onItemSelected: (T) -> Unit, itemLabel: (T) -> String ) { var expanded by remember { mutableStateOf(false) } - Card( + Row( modifier = Modifier .fillMaxWidth() - .clickable { expanded = true }, - colors = CardDefaults.cardColors(containerColor = CardBg), - shape = RoundedCornerShape(16.dp) + .height(56.dp) + .clickable { expanded = true } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(44.dp) - .background(AccentBlue.copy(alpha = 0.15f), CircleShape), - contentAlignment = Alignment.Center - ) { - Icon( - icon, - null, - tint = AccentBlue, - modifier = Modifier.size(22.dp) - ) + Icon( + icon, + null, + tint = SystemBlue, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + label, + fontSize = 17.sp, + color = LabelPrimary, + modifier = Modifier.weight(1f) + ) + Text( + currentValue.substringBefore(" •"), + fontSize = 16.sp, + color = LabelSecondary + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + Icons.Filled.ChevronRight, + null, + tint = LabelTertiary, + modifier = Modifier.size(18.dp) + ) + } + + if (expanded) { + PickerDialog( + title = label, + items = items, + itemLabel = itemLabel, + onDismiss = { expanded = false }, + onItemSelected = { item -> + onItemSelected(item) + expanded = false } + ) + } +} - Spacer(modifier = Modifier.width(14.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - label, - fontSize = 13.sp, - color = TextSecondary - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - currentValue, - color = TextPrimary, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } - - Icon( - Icons.Filled.ArrowDropDown, - null, - tint = TextSecondary, - modifier = Modifier.rotate(if (expanded) 180f else 0f) - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, +@Composable +fun PickerDialog( + title: String, + items: List, + itemLabel: (T) -> String, + onDismiss: () -> Unit, + onItemSelected: (T) -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( modifier = Modifier - .background(CardBg) - .widthIn(min = 200.dp) + .fillMaxWidth() + .heightIn(max = 500.dp), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = DarkCard) ) { - items.forEach { item -> - DropdownMenuItem( - text = { - Text( - itemLabel(item), - color = TextPrimary, - fontSize = 15.sp - ) - }, - onClick = { - onItemSelected(item) - expanded = false - } + Column( + modifier = Modifier.fillMaxWidth() + ) { + // Header + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + title, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = LabelPrimary, + modifier = Modifier.align(Alignment.Center) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(Separator) ) + + // Items list + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + items.forEachIndexed { index, item -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onItemSelected(item) } + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + itemLabel(item), + fontSize = 17.sp, + color = LabelPrimary, + modifier = Modifier.weight(1f) + ) + } + + if (index < items.size - 1) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .padding(start = 20.dp) + .background(Separator) + ) + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(Separator) + ) + + // Cancel button + TextButton( + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + shape = RoundedCornerShape(0.dp) + ) { + Text( + "Cancel", + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + color = SystemBlue + ) + } } } }