package com.hikoncont.manager import android.media.projection.MediaProjection import android.os.Build import android.util.Log import androidx.annotation.RequiresApi import com.hikoncont.MediaProjectionHolder import com.hikoncont.service.AccessibilityRemoteService import com.pedro.common.ConnectChecker import com.pedro.common.VideoCodec import com.pedro.encoder.input.sources.audio.NoAudioSource import com.pedro.encoder.input.sources.video.ScreenSource import com.pedro.library.srt.SrtStream import org.json.JSONObject import kotlin.math.roundToInt @RequiresApi(Build.VERSION_CODES.LOLLIPOP) class SrtStreamManager( private val service: AccessibilityRemoteService, private val listener: Listener ) { interface Listener { fun onStateChanged(state: String, message: String, extra: JSONObject? = null) } data class StartResult( val success: Boolean, val message: String, val extra: JSONObject? = null ) companion object { private const val TAG = "SrtStreamManager" private const val MIN_FPS = 20 private const val MAX_FPS = 60 private const val MIN_LONG_EDGE = 360 private const val MAX_LONG_EDGE = 1920 private const val MIN_BITRATE_BPS = 500_000 private const val MAX_BITRATE_BPS = 12_000_000 } private enum class StreamPriority(val wireValue: String) { SMOOTH("smooth"), BALANCED("balanced"), QUALITY("quality"); companion object { fun fromRaw(raw: String?): StreamPriority { return when (raw?.trim()?.lowercase()) { QUALITY.wireValue -> QUALITY BALANCED.wireValue -> BALANCED else -> SMOOTH } } } } private data class Session( val deviceId: String, val clientId: String, val ingestUrl: String, val width: Int, val height: Int, val fps: Int, val priority: String, val maxLongEdge: Int, val bitrateBps: Int, val startedAt: Long ) private val stateLock = Any() private var stream: SrtStream? = null private var session: Session? = null private var lastBitrateBps = 0L fun isRuntimeSupported(): Boolean { // Dependency exists at compile-time; runtime check is kept explicit for clarity. return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP } fun isStreaming(): Boolean = synchronized(stateLock) { stream?.isStreaming == true } fun start( deviceId: String, clientId: String, ingestUrl: String, requestedFps: Int? = null, requestedMaxLongEdge: Int? = null, requestedBitrateKbps: Int? = null, priorityRaw: String? = null ): StartResult { if (!isRuntimeSupported()) { return StartResult(false, "srt_runtime_not_supported") } if (ingestUrl.isBlank() || !ingestUrl.startsWith("srt://", ignoreCase = true)) { return StartResult(false, "invalid_srt_ingest_url") } val projection = MediaProjectionHolder.getMediaProjection() ?: return StartResult(false, "media_projection_not_ready") val screenConfig = buildScreenConfig( requestedFps = requestedFps, requestedMaxLongEdge = requestedMaxLongEdge, requestedBitrateKbps = requestedBitrateKbps, priorityRaw = priorityRaw ) val preparedExtra = JSONObject().apply { put("width", screenConfig.width) put("height", screenConfig.height) put("fps", screenConfig.fps) put("bitrateBps", screenConfig.bitrateBps) put("priority", screenConfig.priority) put("maxLongEdge", screenConfig.maxLongEdge) if (requestedMaxLongEdge != null) put("requestedMaxLongEdge", requestedMaxLongEdge) if (requestedBitrateKbps != null) put("requestedBitrateKbps", requestedBitrateKbps) put("ingestUrl", ingestUrl) } synchronized(stateLock) { stopInternalLocked() val checker = createConnectChecker() val newStream = try { createStream(projection, checker) } catch (e: Exception) { Log.e(TAG, "Create SRT stream failed", e) return StartResult(false, "srt_stream_create_failed:${e.message ?: "unknown"}") } val videoPrepared = try { newStream.prepareVideo( screenConfig.width, screenConfig.height, screenConfig.bitrateBps, screenConfig.fps, 1, 0 ) } catch (e: Exception) { Log.e(TAG, "Prepare SRT video failed", e) false } if (!videoPrepared) { try { newStream.release() } catch (_: Exception) { } return StartResult(false, "srt_prepare_video_failed", preparedExtra) } // Keep encoder pipeline initialized even when sending video-only. val audioPrepared = try { newStream.prepareAudio(32_000, false, 32 * 1000) } catch (e: Exception) { Log.e(TAG, "Prepare SRT audio failed", e) false } if (!audioPrepared) { try { newStream.release() } catch (_: Exception) { } return StartResult(false, "srt_prepare_audio_failed", preparedExtra) } stream = newStream session = Session( deviceId = deviceId, clientId = clientId, ingestUrl = ingestUrl, width = screenConfig.width, height = screenConfig.height, fps = screenConfig.fps, priority = screenConfig.priority, maxLongEdge = screenConfig.maxLongEdge, bitrateBps = screenConfig.bitrateBps, startedAt = System.currentTimeMillis() ) lastBitrateBps = 0L listener.onStateChanged("starting", "srt_connecting", preparedExtra) try { newStream.startStream(ingestUrl) } catch (e: Exception) { Log.e(TAG, "Start SRT stream failed", e) stopInternalLocked() return StartResult( success = false, message = "srt_start_failed:${e.message ?: "unknown"}", extra = preparedExtra ) } } return StartResult(true, "srt_starting", preparedExtra) } fun stop(reason: String = "remote_stop"): Boolean { val hasSession = synchronized(stateLock) { val exists = stream != null || session != null stopInternalLocked() exists } if (hasSession) { listener.onStateChanged( state = "stopped", message = "srt_stopped", extra = JSONObject().apply { put("reason", reason) } ) } return hasSession } fun release() { synchronized(stateLock) { stopInternalLocked() } } private data class ScreenConfig( val width: Int, val height: Int, val fps: Int, val priority: String, val maxLongEdge: Int, val bitrateBps: Int ) private fun buildScreenConfig( requestedFps: Int?, requestedMaxLongEdge: Int?, requestedBitrateKbps: Int?, priorityRaw: String? ): ScreenConfig { val metrics = service.resources.displayMetrics val rawWidth = metrics.widthPixels.coerceAtLeast(2) val rawHeight = metrics.heightPixels.coerceAtLeast(2) val priority = StreamPriority.fromRaw(priorityRaw) val defaultFps = when (priority) { StreamPriority.SMOOTH -> 40 StreamPriority.BALANCED -> 45 StreamPriority.QUALITY -> 50 } val defaultLongEdge = when (priority) { StreamPriority.SMOOTH -> 960 StreamPriority.BALANCED -> 1152 StreamPriority.QUALITY -> 1280 } val bitrateFactor = when (priority) { StreamPriority.SMOOTH -> 0.055 StreamPriority.BALANCED -> 0.075 StreamPriority.QUALITY -> 0.10 } val profileMinBitrate = when (priority) { StreamPriority.SMOOTH -> 700_000 StreamPriority.BALANCED -> 1_000_000 StreamPriority.QUALITY -> 1_300_000 } val profileMaxBitrate = when (priority) { StreamPriority.SMOOTH -> 4_000_000 StreamPriority.BALANCED -> 7_000_000 StreamPriority.QUALITY -> MAX_BITRATE_BPS } val fps = (requestedFps ?: defaultFps).coerceIn(MIN_FPS, MAX_FPS) val maxLongEdge = (requestedMaxLongEdge ?: defaultLongEdge).coerceIn(MIN_LONG_EDGE, MAX_LONG_EDGE) val sourceLong = maxOf(rawWidth, rawHeight) val sourceShort = minOf(rawWidth, rawHeight) val scale = if (sourceLong > maxLongEdge) { maxLongEdge.toFloat() / sourceLong.toFloat() } else { 1f } val targetLong = ((sourceLong * scale).roundToInt()).coerceAtLeast(2) val targetShort = ((sourceShort * scale).roundToInt()).coerceAtLeast(2) val width = if (rawWidth >= rawHeight) toEven(targetLong) else toEven(targetShort) val height = if (rawWidth >= rawHeight) toEven(targetShort) else toEven(targetLong) val requestedBitrateBps = requestedBitrateKbps ?.takeIf { it > 0 } ?.let { it * 1000 } val bitrateEstimate = (width.toLong() * height.toLong() * fps.toLong() * bitrateFactor).toLong() .coerceAtLeast(MIN_BITRATE_BPS.toLong()) .coerceAtMost(MAX_BITRATE_BPS.toLong()) .toInt() val bitrateBps = (requestedBitrateBps ?: bitrateEstimate) .coerceIn(profileMinBitrate, profileMaxBitrate) .coerceIn(MIN_BITRATE_BPS, MAX_BITRATE_BPS) return ScreenConfig( width = width, height = height, fps = fps, priority = priority.wireValue, maxLongEdge = maxLongEdge, bitrateBps = bitrateBps ) } private fun toEven(value: Int): Int { val safe = value.coerceAtLeast(2) return if (safe % 2 == 0) safe else safe - 1 } private fun createStream(projection: MediaProjection, checker: ConnectChecker): SrtStream { val screenSource = ScreenSource(service.applicationContext, projection) val audioSource = NoAudioSource() return SrtStream(service.applicationContext, checker, screenSource, audioSource).apply { setVideoCodec(VideoCodec.H264) val client = getStreamClient() client.setReTries(2) client.setOnlyVideo(true) client.setLogs(false) } } private fun createConnectChecker(): ConnectChecker { return object : ConnectChecker { override fun onConnectionStarted(url: String) { listener.onStateChanged( state = "starting", message = "srt_connection_started", extra = JSONObject().apply { put("url", url) } ) } override fun onConnectionSuccess() { val current = synchronized(stateLock) { session } listener.onStateChanged( state = "running", message = "srt_connection_success", extra = JSONObject().apply { put("bitrateBps", lastBitrateBps) put("width", current?.width ?: 0) put("height", current?.height ?: 0) put("fps", current?.fps ?: 0) put("priority", current?.priority ?: StreamPriority.SMOOTH.wireValue) put("maxLongEdge", current?.maxLongEdge ?: 0) put("startedAt", current?.startedAt ?: 0) } ) } override fun onConnectionFailed(reason: String) { Log.w(TAG, "SRT connection failed: $reason") synchronized(stateLock) { stopInternalLocked() } listener.onStateChanged( state = "failed", message = "srt_connection_failed:$reason" ) } override fun onDisconnect() { val hadSession = synchronized(stateLock) { val active = stream != null || session != null stopInternalLocked() active } if (hadSession) { listener.onStateChanged( state = "stopped", message = "srt_disconnected" ) } } override fun onAuthError() { listener.onStateChanged("failed", "srt_auth_error") } override fun onAuthSuccess() { listener.onStateChanged("running", "srt_auth_success") } override fun onNewBitrate(bitrate: Long) { lastBitrateBps = bitrate } } } private fun stopInternalLocked() { val old = stream stream = null session = null if (old != null) { try { if (old.isStreaming) { old.stopStream() } } catch (e: Exception) { Log.w(TAG, "Stop SRT stream failed", e) } try { old.release() } catch (e: Exception) { Log.w(TAG, "Release SRT stream failed", e) } } } }