418 lines
14 KiB
Kotlin
418 lines
14 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|
|
}
|