feat: upload latest android source changes
This commit is contained in:
417
app/src/main/java/com/hikoncont/manager/SrtStreamManager.kt
Normal file
417
app/src/main/java/com/hikoncont/manager/SrtStreamManager.kt
Normal file
@@ -0,0 +1,417 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user