refactor: almost ready for v0.1.0

- 优化 `Shared` 中的 `Codec` 枚举,增加显示名称并添加 `isLossyAudio` 函数
- 简化 `Codec`、`VideoSource`、`AudioSource` 和 `CameraFacing` 枚举中的 `fromString` 方法
- 将 `fetchConnectedDeviceInfo` 改为挂起函数以支持协程
- 清理 `PreferenceMigration` 和 `ScrcpyOptions`,提高可读性
- 更新 `Settings` 类,添加 `isDefaultValue` 挂起函数
- 改进 `DeviceWidgets`,增强状态管理和 UI 响应性
This commit is contained in:
Miuzarte
2026-04-06 23:38:22 +08:00
parent ebbf9f6d4b
commit e970c89e8f
27 changed files with 2001 additions and 1799 deletions

View File

@@ -5,7 +5,9 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import io.github.miuzarte.scrcpyforandroid.pages.MainPage
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -14,6 +16,12 @@ class MainActivity : ComponentActivity() {
// initialize settings singleton
Storage.init(applicationContext)
val migration = PreferenceMigration(applicationContext)
runBlocking {
if (migration.needsMigration())
migration.migrate(clearSharedPrefs = true)
}
enableEdgeToEdge()
setContent {

View File

@@ -6,9 +6,7 @@ import android.os.Looper
import android.util.Log
import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.ArrayDeque
@@ -16,15 +14,17 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
/**
* Facade that centralizes video rendering and ADB operations.
* Facade that centralizes video rendering.
*
* Provides helpers for:
* - ADB operations (all suspend functions)
* - Surface/Decoder management for video rendering
* - Control input injection (all suspend functions)
* - Video size and FPS monitoring
*/
class NativeCoreFacade(private val appContext: Context) {
val sessionManager = ScrcpySessionManager(NativeAdbService(appContext))
class NativeCoreFacade private constructor() {
@Volatile
var session: Scrcpy.Session? = null
private set
private val sessionLifecycleMutex = Mutex()
private val surfaceMap = ConcurrentHashMap<String, Surface>()
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
@@ -40,10 +40,7 @@ class NativeCoreFacade(private val appContext: Context) {
private var packetCount: Long = 0
@Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null
@Volatile
private var currentSessionInfo: ScrcpySessionInfo? = null
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
suspend fun close() {
sessionLifecycleMutex.withLock {
@@ -63,6 +60,10 @@ class NativeCoreFacade(private val appContext: Context) {
*/
suspend fun registerVideoSurface(tag: String, surface: Surface) {
sessionLifecycleMutex.withLock {
if (!surface.isValid) {
Log.w(TAG, "registerVideoSurface(): skip invalid surface for tag=$tag")
return
}
val newId = System.identityHashCode(surface)
val oldId = surfaceIdentityMap[tag]
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
@@ -103,12 +104,12 @@ class NativeCoreFacade(private val appContext: Context) {
)
return
}
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId, releasing decoder")
surfaceMap.remove(tag)
surfaceIdentityMap.remove(tag)
if (currentSessionInfo == null) {
decoderMap.remove(tag)?.release()
}
// Always release decoder when surface is unregistered
// This ensures clean state when surface is recreated
decoderMap.remove(tag)?.release()
}
}
@@ -129,7 +130,7 @@ class NativeCoreFacade(private val appContext: Context) {
}
suspend fun scrcpyBackOrScreenOn(action: Int = 0) {
sessionManager.pressBackOrScreenOn(action)
session?.pressBackOrScreenOn(action)
}
/**
@@ -137,9 +138,10 @@ class NativeCoreFacade(private val appContext: Context) {
* Sets up video decoders for registered surfaces.
*/
suspend fun onScrcpySessionStarted(
session: ScrcpySessionInfo,
sessionMgr: ScrcpySessionManager
session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session
) = sessionLifecycleMutex.withLock {
this.session = sessionMgr
currentSessionInfo = session
releaseAllDecoders()
synchronized(bootstrapLock) {
@@ -148,6 +150,10 @@ class NativeCoreFacade(private val appContext: Context) {
}
surfaceMap.forEach { (tag, surface) ->
if (!surface.isValid) {
Log.w(TAG, "onScrcpySessionStarted(): skip invalid surface for tag=$tag")
return@forEach
}
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag")
createOrReplaceDecoder(tag, surface, session)
}
@@ -164,10 +170,16 @@ class NativeCoreFacade(private val appContext: Context) {
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}"
)
}
decoderMap.forEach { (tag, decoder) ->
// Snapshot decoders to avoid feeding released decoders during iteration
val decoders = decoderMap.toMap()
decoders.forEach { (tag, decoder) ->
if (!surfaceIdentityMap.containsKey(tag)) {
return@forEach
}
// Double-check decoder is still in map before feeding
if (decoderMap[tag] != decoder) {
return@forEach
}
runCatching {
decoder.feedAnnexB(
packet.data,
@@ -185,6 +197,7 @@ class NativeCoreFacade(private val appContext: Context) {
* Cleans up decoders and resets state.
*/
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
session = null
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
@@ -202,7 +215,7 @@ class NativeCoreFacade(private val appContext: Context) {
fun get(context: Context): NativeCoreFacade {
return instance ?: synchronized(this) {
instance ?: NativeCoreFacade(context.applicationContext).also { instance = it }
instance ?: NativeCoreFacade().also { instance = it }
}
}
@@ -246,23 +259,26 @@ class NativeCoreFacade(private val appContext: Context) {
* - Newly created decoders are fed with any cached bootstrap packets to allow
* faster playback startup.
*/
private fun createOrReplaceDecoder(tag: String, surface: Surface, session: ScrcpySessionInfo) {
decoderMap.remove(tag)?.release()
val mime = when (session.codec.lowercase()) {
"h264" -> "video/avc"
"h265" -> "video/hevc"
"av1" -> "video/av01"
else -> "video/avc"
private fun createOrReplaceDecoder(tag: String, surface: Surface, session: Scrcpy.Session.SessionInfo) {
if (!surface.isValid) {
Log.w(TAG, "createOrReplaceDecoder(): skip invalid surface for tag=$tag")
return
}
decoderMap.remove(tag)?.release()
Log.i(
TAG,
"createOrReplaceDecoder(): tag=$tag codec=$mime size=${session.width}x${session.height}"
"createOrReplaceDecoder(): tag=$tag codec=${session.codecName} size=${session.width}x${session.height}"
)
val decoder = AnnexBDecoder(
width = session.width,
height = session.height,
outputSurface = surface,
mimeType = mime,
mimeType = when (session.codecName.lowercase()) {
"h264" -> "video/avc"
"h265" -> "video/hevc"
"av1" -> "video/av01"
else -> "video/avc"
},
onOutputSizeChanged = { width, height ->
val current = currentSessionInfo
if (current == null || (current.width == width && current.height == height)) {
@@ -304,7 +320,7 @@ class NativeCoreFacade(private val appContext: Context) {
}
}
private fun cacheBootstrapPacket(packet: ScrcpySessionManager.VideoPacket) {
private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) {
val cached = CachedPacket(
data = packet.data.copyOf(),
ptsUs = packet.ptsUs,
@@ -343,7 +359,7 @@ class NativeCoreFacade(private val appContext: Context) {
* isolated with `runCatching` so one codec failure doesn't stop others.
*/
@Deprecated("TODO: Determine if this is really unnecessary")
private suspend fun ensureVideoConsumerAttached(sessionMgr: ScrcpySessionManager) {
private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) {
sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet)
packetCount += 1
@@ -353,10 +369,16 @@ class NativeCoreFacade(private val appContext: Context) {
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}"
)
}
decoderMap.forEach { (tag, decoder) ->
// Snapshot decoders to avoid feeding released decoders during iteration
val decoders = decoderMap.toMap()
decoders.forEach { (tag, decoder) ->
if (!surfaceIdentityMap.containsKey(tag)) {
return@forEach
}
// Double-check decoder is still in map before feeding
if (decoderMap[tag] != decoder) {
return@forEach
}
runCatching {
decoder.feedAnnexB(
packet.data,
@@ -375,12 +397,4 @@ class NativeCoreFacade(private val appContext: Context) {
}
decoderMap.clear()
}
data class ScrcpySessionInfo(
val width: Int,
val height: Int,
val deviceName: String,
val codec: String,
val controlEnabled: Boolean,
)
}

View File

@@ -1,7 +1,63 @@
package io.github.miuzarte.scrcpyforandroid.constants
object ScrcpyPresets {
val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) // px
val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120)
val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) // Kbps
/**
* A generic preset class that holds a list of preset values and provides
* methods to find the index of a value or its nearest match.
*
* @param T The type of preset values (typically Int)
* @param values The list of preset values
*/
class Preset<T : Comparable<T>>(val values: List<T>) {
val lastIndex: Int get() = values.lastIndex
val size: Int get() = values.size
val indices: IntRange get() = values.indices
operator fun get(index: Int): T = values[index]
/**
* Find the index of the exact value or the nearest preset.
* For numeric types, finds the closest value by absolute difference.
*/
fun indexOfOrNearest(value: T): Int {
val exact = values.indexOf(value)
if (exact >= 0) return exact
// For numeric types, find nearest by comparing
return values.withIndex().minByOrNull { (_, preset) ->
when {
preset is Number && value is Number -> {
kotlin.math.abs(preset.toDouble() - value.toDouble())
}
else -> if (preset > value) 1.0 else -1.0
}
}?.index ?: 0
}
}
/**
* Extension function for Int presets to find index from Int value.
*/
fun Preset<Int>.indexOfOrNearest(raw: Int): Int {
val exact = values.indexOf(raw)
if (exact >= 0) return exact
val nearest = values.withIndex().minByOrNull { (_, preset) ->
kotlin.math.abs(preset - raw)
}
return nearest?.index ?: 0
}
/**
* Extension function for Int presets to find index from String value.
*/
fun Preset<Int>.indexOfOrNearest(raw: String): Int {
if (raw.isBlank()) return 0
val value = raw.toIntOrNull() ?: return 0
return indexOfOrNearest(value)
}
object ScrcpyPresets {
val MaxSize = Preset(listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)) // px
val MaxFPS = Preset(listOf(0, 24, 30, 45, 60, 90, 120)) // fps
val AudioBitRate = Preset(listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps
val CameraFps = Preset(listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)) // fps
}

View File

@@ -160,7 +160,7 @@ data class ConnectionTarget(
val host: String,
val port: Int = Defaults.ADB_PORT,
) {
fun marshalToString(): String = "$host:$port"
override fun toString(): String = "$host:$port"
companion object {
fun unmarshalFrom(s: String): ConnectionTarget? {

View File

@@ -0,0 +1,75 @@
package io.github.miuzarte.scrcpyforandroid.models
class ScrcpyOptions {
data class NewDisplay(val width: Int? = null, val height: Int? = null, val dpi: Int? = null) {
override fun toString() = buildString {
if (width != null && width > 0 && height != null && height > 0)
append("${width}x${height}")
if (dpi != null && dpi > 0)
append("/$dpi")
}
companion object {
fun parseFrom(width: String, height: String, dpi: String) =
NewDisplay(
width = width.toIntOrNull()?.takeIf { it > 0 },
height = height.toIntOrNull()?.takeIf { it > 0 },
dpi = dpi.toIntOrNull()?.takeIf { it > 0 },
)
fun parseFrom(input: String): NewDisplay {
// [<width>x<height>][/<dpi>]
val trimmed = input.trim()
if (trimmed.isEmpty()) return NewDisplay()
val slashIndex = trimmed.indexOf('/')
val sizePart = if (slashIndex >= 0) trimmed.substring(0, slashIndex) else trimmed
val dpiPart = if (slashIndex >= 0) trimmed.substring(slashIndex + 1) else ""
val xIndex = sizePart.indexOf('x')
var widthPart = ""
var heightPart = ""
if (xIndex >= 0) {
widthPart = sizePart.substring(0, xIndex)
heightPart = sizePart.substring(xIndex + 1)
}
return parseFrom(widthPart, heightPart, dpiPart)
}
}
}
data class Crop(
val width: Int? = null,
val height: Int? = null,
val x: Int? = null,
val y: Int? = null,
) {
override fun toString() =
if (width != null && width > 0
&& height != null && height > 0
&& x != null && x > 0
&& y != null && y > 0
) "$width:$height:$x:$y"
else ""
companion object {
fun parseFrom(width: String, height: String, x: String, y: String) =
Crop(
width = width.toIntOrNull()?.takeIf { it > 0 },
height = height.toIntOrNull()?.takeIf { it > 0 },
x = x.toIntOrNull()?.takeIf { it > 0 },
y = y.toIntOrNull()?.takeIf { it > 0 },
)
fun parseFrom(input: String): Crop {
// width:height:x:y
val parts = input.split(':', limit = 4)
return if (parts.size >= 4)
parseFrom(parts[0], parts[1], parts[2], parts[3])
else Crop()
}
}
}
}

View File

@@ -42,6 +42,9 @@ class AnnexBDecoder(
private var released = false
init {
if (!outputSurface.isValid) {
throw IllegalStateException("Cannot initialize decoder: output surface is not valid")
}
val format = MediaFormat.createVideoFormat(mimeType, width, height)
if (sps != null) {
format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(sps))

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.os.Build
import android.util.Base64
import android.util.Log
import io.github.miuzarte.scrcpyforandroid.storage.AdbClientData
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking
@@ -394,7 +393,12 @@ internal class DirectAdbConnection(
}
}
fun isAlive(): Boolean = !closed && !socket.isClosed && socket.isConnected
fun isAlive(): Boolean {
val isClosed = socket.isClosed
val isConnected = socket.isConnected
Log.d(TAG, "isClose: $isClosed, isConnected: $isConnected")
return !closed && !isClosed && isConnected
}
override fun close() {
if (!closed) {

View File

@@ -2,8 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.nio.file.Path
import kotlin.time.Duration
@@ -14,6 +16,8 @@ import kotlin.time.Duration
*
* Methods use Mutex for thread-safety because the underlying transport is single-connection
* and may be accessed from multiple coroutines.
*
* All network operations are executed on Dispatchers.IO.
*/
class NativeAdbService(appContext: Context) {
private val transport = DirectAdbTransport(appContext)
@@ -82,35 +86,39 @@ class NativeAdbService(appContext: Context) {
host: String,
port: Int,
timeout: Duration = Duration.INFINITE,
) = mutex.withLock {
Log.i(TAG, "connect(): host=$host port=$port")
) = withContext(Dispatchers.IO) {
mutex.withLock {
Log.i(TAG, "connect(): host=$host port=$port")
if (connection != null
&& connection!!.isAlive()
&& connectedHost == host
&& connectedPort == port
) {
return@withLock
}
disconnectInternal()
if (connection != null
&& connection!!.isAlive()
&& connectedHost == host
&& connectedPort == port
) {
return@withLock
}
disconnectInternal()
try {
val conn = withTimeout(timeout) { transport.connect(host, port) }
connection = conn
connectedHost = host
connectedPort = port
} catch (e: Exception) {
Log.e(TAG, "connect(): failed host=$host port=$port", e)
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e)
try {
val conn = withTimeout(timeout) { transport.connect(host, port) }
connection = conn
connectedHost = host
connectedPort = port
} catch (e: Exception) {
Log.e(TAG, "connect(): failed host=$host port=$port", e)
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e)
}
}
}
/**
* Close the current ADB connection immediately.
*/
suspend fun disconnect() = mutex.withLock {
disconnectInternal()
suspend fun disconnect() = withContext(Dispatchers.IO) {
mutex.withLock {
disconnectInternal()
}
}
suspend fun isConnected(): Boolean = mutex.withLock {
@@ -121,7 +129,9 @@ class NativeAdbService(appContext: Context) {
* Execute a shell command on the connected device and return stdout text.
*/
suspend fun shell(command: String): String = mutex.withLock {
requireConnection().shell(command)
val response = requireConnection().shell(command)
Log.d(TAG, "command: $command, response: $response")
response
}
suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock {
@@ -136,7 +146,9 @@ class NativeAdbService(appContext: Context) {
requireConnection().openStream("localabstract:$name")
}
suspend fun close() = disconnect()
suspend fun close() {
disconnect()
}
private fun disconnectInternal() {
runCatching { connection?.close() }

View File

@@ -1,918 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.util.Log
import android.view.KeyEvent
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.ServerParams
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.BufferedInputStream
import java.io.BufferedReader
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
import java.io.InputStreamReader
import java.nio.file.Path
import java.security.SecureRandom
import java.util.ArrayDeque
import kotlin.concurrent.thread
import kotlin.math.roundToInt
class ScrcpySessionManager(private val adbService: NativeAdbService) {
private val mutex = Mutex()
@Volatile
private var activeSession: ActiveSession? = null
@Volatile
private var videoConsumer: ((VideoPacket) -> Unit)? = null
@Volatile
private var videoReaderThread: Thread? = null
@Volatile
private var audioConsumer: ((AudioPacket) -> Unit)? = null
@Volatile
private var audioReaderThread: Thread? = null
@Volatile
private var lastServerCommand: String? = null
private val serverLogBuffer = ArrayDeque<String>()
/**
* Start a scrcpy session.
*
* Responsibilities:
* - Pushes the server artifact to the device, constructs the server command,
* and opens the server shell stream.
* - Opens the required abstract sockets (video/audio/control) with retries and
* reads initial session metadata (device name, codec, resolution).
* - Initializes an [ActiveSession] which holds socket streams and reader threads.
*
* Threading notes:
* - Uses Mutex for thread-safety to avoid concurrent starts/stops.
* - It may block while interacting with adb; callers should execute it off the UI
* thread when appropriate.
*/
suspend fun start(
serverJarPath: Path,
serverCommand: String,
scid: UInt,
options: ClientOptions,
): SessionInfo = mutex.withLock {
stopInternal()
serverLogBuffer.clear()
val socketName = socketNameFor(scid.toInt())
try {
lastServerCommand = serverCommand
Log.i(
TAG,
"start(): socket=$socketName codec=${options.videoCodec.string} audio=${options.audio} audioCodec=${options.audioCodec.string}"
)
val serverStream = adbService.openShellStream(serverCommand)
val serverLogThread = startServerLogThread(serverStream, socketName)
Thread.sleep(SERVER_BOOT_DELAY_MS)
// The first socket always carries device meta (and dummy byte).
val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true)
val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream))
var videoStream: AdbSocketStream? = null
var videoInput: DataInputStream? = null
var audioStream: AdbSocketStream? = null
var audioInput: DataInputStream? = null
var controlStream: AdbSocketStream? = null
when {
options.video -> {
videoStream = firstStream
videoInput = firstInput
}
options.audio -> {
audioStream = firstStream
audioInput = firstInput
}
options.control -> {
controlStream = firstStream
}
else -> {
throw IllegalArgumentException("At least one of video/audio/control must be enabled")
}
}
if (options.video && videoStream == null) {
val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
videoStream = vStream
videoInput = DataInputStream(BufferedInputStream(vStream.inputStream))
}
if (options.audio && audioStream == null) {
val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
audioStream = aStream
audioInput = DataInputStream(BufferedInputStream(aStream.inputStream))
}
if (options.control && controlStream == null) {
controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
}
val deviceName = readDeviceName(firstInput)
val audioCodecId =
if (options.audio) audioCodecIdFromName(options.audioCodec.string) else 0
val codecId: Int
val width: Int
val height: Int
if (options.video) {
val vInput = checkNotNull(videoInput)
codecId = vInput.readInt()
width = vInput.readInt()
height = vInput.readInt()
} else {
codecId = 0
width = 0
height = 0
}
val sessionInfo = SessionInfo(
deviceName = deviceName,
codecId = codecId,
codecName = codecName(codecId),
width = width,
height = height,
audioCodecId = audioCodecId,
controlEnabled = controlStream != null,
)
activeSession = ActiveSession(
info = sessionInfo,
socketName = socketName,
serverStream = serverStream,
serverLogThread = serverLogThread,
videoStream = videoStream,
videoInput = videoInput,
audioStream = audioStream,
audioInput = audioInput,
controlStream = controlStream,
controlWriter = controlStream?.outputStream?.let {
ScrcpyControlWriter(
DataOutputStream(it)
)
},
)
return sessionInfo
} catch (t: Throwable) {
val tail = snapshotServerLogs()
val detail = if (tail.isBlank()) "" else " | server_log_tail=\n$tail"
throw IllegalStateException("scrcpy start failed: ${t.message}$detail", t)
}
}
/**
* Attach a video consumer callback.
*
* - Spawns a dedicated `scrcpy-video-reader` thread that reads framed Annex B
* packets from the video socket and delivers `VideoPacket` instances to [consumer].
* - The reader thread stops when the session ends or the socket is closed.
* - Consumers should be resilient to occasional dropped packets or reader errors.
*/
suspend fun attachVideoConsumer(consumer: (VideoPacket) -> Unit): Unit = mutex.withLock {
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
val vInput = session.videoInput ?: return
val vStream = session.videoStream ?: return
videoConsumer = consumer
if (videoReaderThread?.isAlive == true) {
return
}
videoReaderThread = thread(start = true, name = "scrcpy-video-reader") {
while (activeSession === session && !vStream.closed) {
try {
val ptsAndFlags = vInput.readLong()
val packetSize = vInput.readInt()
if (packetSize <= 0) {
continue
}
val payload = ByteArray(packetSize)
vInput.readFully(payload)
val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L
val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L
val ptsUs = ptsAndFlags and PACKET_PTS_MASK
videoConsumer?.invoke(
VideoPacket(
data = payload,
ptsUs = ptsUs,
isConfig = config,
isKeyFrame = keyFrame,
),
)
} catch (_: EOFException) {
break
} catch (_: InterruptedException) {
// Ignore transient interrupts while session remains active.
if (activeSession !== session || vStream.closed) {
break
}
Thread.interrupted()
} catch (e: Exception) {
Log.w(TAG, "video reader failed", e)
break
}
}
}
}
suspend fun clearVideoConsumer() = mutex.withLock {
videoConsumer = null
}
/**
* Attach an audio consumer callback.
*
* - Similar to the video consumer, this starts a `scrcpy-audio-reader` thread
* which reads audio packets and dispatches `AudioPacket` to the provided callback.
* - The function reads the audio stream header to determine whether audio is
* available and exits early if disabled.
*/
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
val aInput = session.audioInput ?: return // audio disabled or unavailable
val aStream = session.audioStream ?: return
audioConsumer = consumer
if (audioReaderThread?.isAlive == true) return
audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") {
val streamCodecId = try {
aInput.readInt()
} catch (e: Exception) {
Log.w(TAG, "audio codec header read failed", e)
return@thread
}
when (streamCodecId) {
AUDIO_DISABLED -> {
Log.w(TAG, "audio disabled by server")
return@thread
}
AUDIO_ERROR -> {
Log.e(TAG, "audio stream configuration error from server")
return@thread
}
else -> {
Log.i(TAG, "audio stream codec=0x${streamCodecId.toUInt().toString(16)}")
}
}
if (session.info.audioCodecId != 0 && streamCodecId != session.info.audioCodecId) {
Log.w(
TAG,
"audio codec mismatch: requested=0x${
session.info.audioCodecId.toUInt().toString(16)
} stream=0x${streamCodecId.toUInt().toString(16)}",
)
}
while (activeSession === session && !aStream.closed) {
try {
val ptsAndFlags = aInput.readLong()
val packetSize = aInput.readInt()
if (packetSize <= 0) continue
val payload = ByteArray(packetSize)
aInput.readFully(payload)
val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L
val ptsUs = ptsAndFlags and PACKET_PTS_MASK
audioConsumer?.invoke(
AudioPacket(
data = payload,
ptsUs = ptsUs,
isConfig = isConfig
)
)
} catch (_: EOFException) {
break
} catch (_: InterruptedException) {
if (activeSession !== session || aStream.closed) break
Thread.interrupted()
} catch (e: Exception) {
Log.w(TAG, "audio reader failed", e)
break
}
}
}
}
suspend fun clearAudioConsumer() = mutex.withLock {
audioConsumer = null
}
/**
* Inject a keycode event to the control channel.
*
* - Requires an active control channel; throws if absent.
* - Uses Mutex to serialize control writes.
*/
suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) =
mutex.withLock {
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
}
suspend fun injectText(text: String) = mutex.withLock {
requireControlWriter().injectText(text)
}
/**
* Inject a touch event to the control channel.
*
* - Coordinates are expected in device pixels and are written together with
* screen dimensions so the server can interpret them correctly.
* - Uses Mutex to serialize control writes.
*/
suspend fun injectTouch(
action: Int,
pointerId: Long,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
actionButton: Int,
buttons: Int,
) = mutex.withLock {
requireControlWriter().injectTouch(
action,
pointerId,
x,
y,
screenWidth,
screenHeight,
pressure,
actionButton,
buttons
)
}
suspend fun injectScroll(
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int
) = mutex.withLock {
requireControlWriter().injectScroll(
x,
y,
screenWidth,
screenHeight,
hScroll,
vScroll,
buttons
)
}
suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
requireControlWriter().pressBackOrScreenOn(action)
}
suspend fun setDisplayPower(on: Boolean) = mutex.withLock {
requireControlWriter().setDisplayPower(on)
}
/**
* Stop the active session and clean up reader threads and streams.
*
* - Interrupts and joins reader threads with short timeouts, closes sockets,
* and clears state. It is safe to call from any thread.
*/
suspend fun stop() = mutex.withLock {
stopInternal()
}
private fun stopInternal() {
val session = activeSession ?: return
activeSession = null
videoConsumer = null
audioConsumer = null
if (Thread.currentThread() !== videoReaderThread) {
runCatching { videoReaderThread?.interrupt() }
runCatching { videoReaderThread?.join(300) }
}
videoReaderThread = null
if (Thread.currentThread() !== audioReaderThread) {
runCatching { audioReaderThread?.interrupt() }
runCatching { audioReaderThread?.join(300) }
}
audioReaderThread = null
runCatching { session.controlStream?.close() }
runCatching { session.audioStream?.close() }
runCatching { session.videoStream?.close() }
runCatching { session.serverStream.close() }
if (Thread.currentThread() !== session.serverLogThread) {
runCatching { session.serverLogThread.interrupt() }
runCatching { session.serverLogThread.join(300) }
}
}
fun isStarted(): Boolean = activeSession != null
fun getLastServerCommand(): String? = lastServerCommand
// TODO: 合并几个 --list-xxxx
suspend fun listEncoders(
serverJarPath: Path,
serverParams: ServerParams,
serverVersion: String = "3.3.4",
serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH,
): EncoderLists = mutex.withLock {
val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
adbService.push(serverJarPath, targetPath)
val cmd = buildServerCommand(
targetPath = targetPath,
serverParams = serverParams,
serverVersion = serverVersion,
)
Log.i(TAG, "listEncoders(): cmd=$cmd")
// scrcpy encoder list is printed in logs, so merge stderr into stdout.
val output = adbService.shell("$cmd 2>&1")
val parsed = parseEncoderLists(output)
val preview = output.lineSequence().take(40).joinToString("\n")
Log.i(
TAG,
"listEncoders(): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview",
)
return parsed.copy(rawOutput = output)
}
suspend fun listCameraSizes(
serverJarPath: Path,
serverParams: ServerParams,
serverVersion: String = "3.3.4",
serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH,
): CameraSizeLists = mutex.withLock {
val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
adbService.push(serverJarPath, targetPath)
val cmd = buildServerCommand(
targetPath = targetPath,
serverParams = serverParams,
serverVersion = serverVersion,
)
Log.i(TAG, "listCameraSizes(): cmd=$cmd")
val output = adbService.shell("$cmd 2>&1")
val parsed = parseCameraSizeLists(output)
val preview = output.lineSequence().take(40).joinToString("\n")
Log.i(
TAG,
"listCameraSizes(): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview",
)
return parsed.copy(rawOutput = output)
}
private fun requireControlWriter(): ScrcpyControlWriter {
return activeSession?.controlWriter
?: throw IllegalStateException("scrcpy control channel not available")
}
private fun buildServerCommand(
targetPath: String,
serverParams: ServerParams,
serverVersion: String,
): String {
val serverArgs = serverParams.build()
return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server $serverVersion $serverArgs"
}
private fun parseEncoderLists(output: String): EncoderLists {
val video = LinkedHashSet<String>()
val audio = LinkedHashSet<String>()
val videoTypes = linkedMapOf<String, String>()
val audioTypes = linkedMapOf<String, String>()
VIDEO_ENCODER_REGEX.findAll(output).forEach { match ->
video.add(match.groupValues[1])
}
AUDIO_ENCODER_REGEX.findAll(output).forEach { match ->
audio.add(match.groupValues[1])
}
// Fallback for log formats that include codec+encoder in one line.
VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
video.add(match.groupValues[1])
}
AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
audio.add(match.groupValues[1])
}
VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
val name = match.groupValues[1]
val type = match.groupValues[2]
if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) {
videoTypes[name] = type
}
}
AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
val name = match.groupValues[1]
val type = match.groupValues[2]
if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) {
audioTypes[name] = type
}
}
return EncoderLists(
videoEncoders = video.toList(),
audioEncoders = audio.toList(),
videoEncoderTypes = videoTypes,
audioEncoderTypes = audioTypes,
)
}
private fun parseCameraSizeLists(output: String): CameraSizeLists {
val sizes = LinkedHashSet<String>()
CAMERA_SIZE_REGEX.findAll(output).forEach { match ->
sizes.add(match.groupValues[1])
}
CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match ->
sizes.add(match.groupValues[1])
}
return CameraSizeLists(sizes = sizes.toList())
}
private fun startServerLogThread(serverStream: AdbSocketStream, socketName: String): Thread {
return thread(start = true, name = "scrcpy-server-log") {
try {
BufferedReader(
InputStreamReader(
serverStream.inputStream,
Charsets.UTF_8
)
).use { reader ->
while (true) {
val line = reader.readLine() ?: break
// Use synchronized block for thread-safe log buffer access
synchronized(serverLogBuffer) {
if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) {
serverLogBuffer.removeFirst()
}
serverLogBuffer.addLast(line)
}
Log.i(TAG, "[server:$socketName] $line")
}
}
} catch (e: Exception) {
if (activeSession != null) {
Log.w(TAG, "server log thread failed", e)
}
}
}
}
private fun snapshotServerLogs(maxLines: Int = 120): String {
val snapshot = synchronized(serverLogBuffer) {
if (serverLogBuffer.isEmpty()) {
return ""
}
val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES)
serverLogBuffer.toList().takeLast(take)
}
return snapshot.joinToString("\n")
}
/**
* Open an abstract adb socket with retry.
*
* - Retries a number of times with a short delay (useful during server startup).
* - Optionally expects a dummy byte on the stream to validate the server handshake.
*/
private suspend fun openAbstractSocketWithRetry(
socketName: String,
expectDummyByte: Boolean
): AdbSocketStream {
var lastEx: Exception? = null
repeat(CONNECT_RETRY_COUNT) { attempt ->
try {
val stream = adbService.openAbstractSocket(socketName)
if (expectDummyByte) {
val value = stream.inputStream.read()
if (value < 0) {
stream.close()
throw EOFException("scrcpy dummy byte missing")
}
}
return stream
} catch (e: Exception) {
lastEx = e
if (attempt < CONNECT_RETRY_COUNT - 1) Thread.sleep(CONNECT_RETRY_DELAY_MS)
}
}
throw IllegalStateException("Unable to open scrcpy socket '$socketName'", lastEx)
}
private fun readDeviceName(input: DataInputStream): String {
val buffer = ByteArray(DEVICE_NAME_FIELD_LENGTH)
input.readFully(buffer)
val firstZero = buffer.indexOf(0)
val length = if (firstZero >= 0) firstZero else buffer.size
return buffer.copyOf(length).toString(Charsets.UTF_8)
}
private fun codecName(codecId: Int): String {
return when (codecId) {
VIDEO_CODEC_H264 -> "h264"
VIDEO_CODEC_H265 -> "h265"
VIDEO_CODEC_AV1 -> "av1"
else -> "unknown"
}
}
private fun audioCodecIdFromName(name: String): Int {
return when (name.lowercase()) {
"opus" -> AUDIO_CODEC_OPUS
"aac" -> AUDIO_CODEC_AAC
"raw" -> AUDIO_CODEC_RAW
"flac" -> AUDIO_CODEC_FLAC
else -> 0
}
}
data class SessionInfo(
val deviceName: String,
val codecId: Int,
val codecName: String,
val width: Int,
val height: Int,
val audioCodecId: Int = 0,
val controlEnabled: Boolean,
)
data class VideoPacket(
val data: ByteArray,
val ptsUs: Long,
val isConfig: Boolean,
val isKeyFrame: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VideoPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (isKeyFrame != other.isKeyFrame) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = ptsUs.hashCode()
result = 31 * result + isConfig.hashCode()
result = 31 * result + isKeyFrame.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
data class AudioPacket(
val data: ByteArray,
val ptsUs: Long,
val isConfig: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AudioPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = ptsUs.hashCode()
result = 31 * result + isConfig.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
data class ScrcpyStartOptions(
val serverVersion: String = "3.3.4",
val serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH,
val video: Boolean = true,
val audio: Boolean = true,
val control: Boolean = true,
val cleanup: Boolean = true,
val maxSize: Int = 0,
val maxFps: Float = 0f,
val videoBitRate: Int = 8_000_000,
val videoCodec: String = "h264",
val audioBitRate: Int = 128_000,
val audioCodec: String = "opus",
val videoEncoder: String = "",
val videoCodecOptions: String = "",
val audioEncoder: String = "",
val audioCodecOptions: String = "",
val audioDup: Boolean = false,
val audioSource: String = "",
val videoSource: String = "display",
val cameraId: String = "",
val cameraFacing: String = "",
val cameraSize: String = "",
val cameraAr: String = "",
val cameraFps: Int = 0,
val cameraHighSpeed: Boolean = false,
val newDisplay: String = "",
val displayId: Int? = null,
val crop: String = "",
val listEncoders: Boolean = false,
val listCameraSizes: Boolean = false,
)
data class EncoderLists(
val videoEncoders: List<String>,
val audioEncoders: List<String>,
val videoEncoderTypes: Map<String, String> = emptyMap(),
val audioEncoderTypes: Map<String, String> = emptyMap(),
val rawOutput: String = "",
)
data class CameraSizeLists(
val sizes: List<String>,
val rawOutput: String = "",
)
private data class ActiveSession(
val info: SessionInfo,
val socketName: String,
val serverStream: AdbSocketStream,
val serverLogThread: Thread,
val videoStream: AdbSocketStream?,
val videoInput: DataInputStream?,
val audioStream: AdbSocketStream?,
val audioInput: DataInputStream?,
val controlStream: AdbSocketStream?,
val controlWriter: ScrcpyControlWriter?,
)
private class ScrcpyControlWriter(private val output: DataOutputStream) {
@Synchronized
fun injectKeycode(action: Int, keycode: Int, repeat: Int, metaState: Int) {
output.writeByte(TYPE_INJECT_KEYCODE)
output.writeByte(action)
output.writeInt(keycode)
output.writeInt(repeat)
output.writeInt(metaState)
output.flush()
}
@Synchronized
fun injectText(text: String) {
val bytes = text.toByteArray(Charsets.UTF_8)
output.writeByte(TYPE_INJECT_TEXT)
output.writeInt(bytes.size)
output.write(bytes)
output.flush()
}
@Synchronized
fun injectTouch(
action: Int,
pointerId: Long,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
actionButton: Int,
buttons: Int,
) {
output.writeByte(TYPE_INJECT_TOUCH_EVENT)
output.writeByte(action)
output.writeLong(pointerId)
writePosition(x, y, screenWidth, screenHeight)
output.writeShort(encodeUnsignedFixedPoint16(pressure))
output.writeInt(actionButton)
output.writeInt(buttons)
output.flush()
}
@Synchronized
fun injectScroll(
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int
) {
output.writeByte(TYPE_INJECT_SCROLL_EVENT)
writePosition(x, y, screenWidth, screenHeight)
output.writeShort(encodeSignedFixedPoint16(hScroll / 16f))
output.writeShort(encodeSignedFixedPoint16(vScroll / 16f))
output.writeInt(buttons)
output.flush()
}
@Synchronized
fun pressBackOrScreenOn(action: Int) {
output.writeByte(TYPE_BACK_OR_SCREEN_ON)
output.writeByte(action)
output.flush()
}
@Synchronized
fun setDisplayPower(on: Boolean) {
output.writeByte(TYPE_SET_DISPLAY_POWER)
output.writeBoolean(on)
output.flush()
}
private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
output.writeInt(x)
output.writeInt(y)
output.writeShort(screenWidth)
output.writeShort(screenHeight)
}
private fun encodeUnsignedFixedPoint16(value: Float): Int {
val clamped = value.coerceIn(0f, 1f)
return if (clamped >= 1f) {
0xffff
} else {
(clamped * 65536f).roundToInt().coerceIn(0, 0xfffe)
}
}
private fun encodeSignedFixedPoint16(value: Float): Int {
val clamped = value.coerceIn(-1f, 1f)
if (clamped >= 1f) {
return 0x7fff
}
if (clamped <= -1f) {
return -0x8000
}
return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe)
}
}
companion object {
const val DEFAULT_SERVER_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
private const val TAG = "ScrcpySessionManager"
private const val SERVER_BOOT_DELAY_MS = 200L
private const val SERVER_LOG_BUFFER_MAX_LINES = 400
private const val CONNECT_RETRY_COUNT = 100
private const val CONNECT_RETRY_DELAY_MS = 100L
private const val DEVICE_NAME_FIELD_LENGTH = 64
private const val PACKET_FLAG_CONFIG = 1L shl 63
private const val PACKET_FLAG_KEY_FRAME = 1L shl 62
private const val PACKET_PTS_MASK = (1L shl 62) - 1
private const val VIDEO_CODEC_H264 = 0x68323634
private const val VIDEO_CODEC_H265 = 0x68323635
private const val VIDEO_CODEC_AV1 = 0x00617631
private const val AUDIO_CODEC_OPUS = 0x6f707573
private const val AUDIO_CODEC_AAC = 0x00616163
private const val AUDIO_CODEC_FLAC = 0x666c6163
private const val AUDIO_CODEC_RAW = 0x00726177
// Audio stream disable codes from server (writeDisableStream)
private const val AUDIO_DISABLED = 0
private const val AUDIO_ERROR = 1
private const val TYPE_INJECT_KEYCODE = 0
private const val TYPE_INJECT_TEXT = 1
private const val TYPE_INJECT_TOUCH_EVENT = 2
private const val TYPE_INJECT_SCROLL_EVENT = 3
private const val TYPE_BACK_OR_SCREEN_ON = 4
private const val TYPE_SET_DISPLAY_POWER = 10
private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)")
private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)")
private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""")
private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""")
private val VIDEO_ENCODER_TYPE_REGEX =
Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""")
private val AUDIO_ENCODER_TYPE_REGEX =
Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""")
private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)")
private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b")
private val random = SecureRandom()
private fun socketNameFor(scid: Int): String {
return "scrcpy_%08x".format(scid)
}
}
}

View File

@@ -1,5 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -11,25 +12,33 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.ServerParams
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState
@@ -42,82 +51,19 @@ import top.yukonga.miuix.kmp.extra.SuperSpinner
import top.yukonga.miuix.kmp.extra.SuperSwitch
import kotlin.math.roundToInt
private val AUDIO_SOURCE_OPTIONS = listOf(
"output" to "output",
"playback" to "playback",
"mic" to "mic",
"mic-unprocessed" to "mic-unprocessed",
"mic-camcorder" to "mic-camcorder",
"mic-voice-recognition" to "mic-voice-recognition",
"mic-voice-communication" to "mic-voice-communication",
"voice-call" to "voice-call",
"voice-call-uplink" to "voice-call-uplink",
"voice-call-downlink" to "voice-call-downlink",
"voice-performance" to "voice-performance",
"custom" to "自定义",
)
// TODO: Scrcpy.VideoSource
private val VIDEO_SOURCE_OPTIONS = listOf(
"display" to "display",
"camera" to "camera",
)
private val CAMERA_FACING_OPTIONS = listOf(
"" to "默认",
"front" to "front",
"back" to "back",
"external" to "external",
)
private val CAMERA_FPS_PRESETS = listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)
@Composable
internal fun AdvancedConfigPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
snackbarHostState: SnackbarHostState,
cameraSizeOptions: SnapshotStateList<String>,
cameraSizeDropdownItems: List<String>,
videoEncoderDropdownItems: List<String>,
videoEncoderTypeMap: Map<String, String>,
videoEncoderIndex: Int,
audioEncoderDropdownItems: List<String>,
audioEncoderTypeMap: Map<String, String>,
audioEncoderIndex: Int,
onRefreshEncoders: () -> Unit,
onRefreshCameraSizes: () -> Unit,
scrcpy: Scrcpy,
) {
val scrcpyOptions = Storage.scrcpyOptions
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName ->
if (encoderName == "默认") {
SpinnerEntry(title = encoderName)
} else {
val type = resolveEncoderTypeLabel(videoEncoderTypeMap[encoderName])
SpinnerEntry(
title = encoderName,
summary = type.ifBlank { null },
)
}
}
val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName ->
if (encoderName == "默认") {
SpinnerEntry(title = encoderName)
} else {
val type = resolveEncoderTypeLabel(audioEncoderTypeMap[encoderName])
SpinnerEntry(
title = encoderName,
summary = type.ifBlank { null },
)
}
}
var refreshBusy by remember { mutableStateOf(false) }
// TODO: handle custom value
// TODO: handle empty input
@@ -126,100 +72,164 @@ internal fun AdvancedConfigPage(
var video by scrcpyOptions.video.asMutableState()
var videoSource by scrcpyOptions.videoSource.asMutableState()
val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second }
val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst {
it.first == videoSource
}.let { if (it >= 0) it else 0 }
val videoSourceItems = remember { Shared.VideoSource.entries.map { it.string } }
val videoSourceIndex = remember(videoSource) {
Shared.VideoSource.entries.indexOfFirst { it.string == videoSource }.coerceAtLeast(0)
}
var displayId by scrcpyOptions.displayId.asMutableState()
var cameraId by scrcpyOptions.cameraId.asMutableState()
var cameraFacing by scrcpyOptions.cameraFacing.asMutableState()
val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second }
val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst {
it.first == cameraFacing
}.let { if (it >= 0) it else 0 }
var cameraSize by scrcpyOptions.cameraSize.asMutableState()
val cameraSizeIndex = when (cameraSize) {
"custom" -> cameraSizeOptions.size + 1
in cameraSizeOptions -> cameraSizeOptions.indexOf(cameraSize) + 1
else -> 0
val cameraFacingItems = remember {
listOf("默认") + Shared.CameraFacing.entries.drop(1).map { it.string }
}
val cameraFacingIndex = remember(cameraFacing) {
if (cameraFacing.isEmpty()) {
0
} else {
val idx = Shared.CameraFacing.entries.indexOfFirst { it.string == cameraFacing }
if (idx > 0) idx else 0
}
}
var cameraSize by scrcpyOptions.cameraSize.asMutableState()
var cameraSizeCustom by scrcpyOptions.cameraSizeCustom.asMutableState()
var cameraSizeUseCustom by scrcpyOptions.cameraSizeUseCustom.asMutableState()
var cameraSizeCustomInput by rememberSaveable { mutableStateOf(cameraSizeCustom) }
val cameraSizeDropdownItems = rememberSaveable(scrcpy.cameraSizes) {
listOf("自动", "自定义") + scrcpy.cameraSizes
}
var cameraSizeDropdownIndex by rememberSaveable {
mutableIntStateOf(
when {
cameraSizeUseCustom -> 1 // "自定义"
cameraSize.isEmpty() -> 0 // "自动"
cameraSize in scrcpy.cameraSizes -> scrcpy.cameraSizes.indexOf(cameraSize) + 2
else -> 0 // 默认自动
}
)
}
var cameraAr by scrcpyOptions.cameraAr.asMutableState()
var cameraFps by scrcpyOptions.cameraFps.asMutableState()
val cameraFpsPresetIndex = presetIndexFromInputForAdvancedPage(
cameraFps, CAMERA_FPS_PRESETS
)
val cameraFpsPresetIndex = ScrcpyPresets.CameraFps.indexOfOrNearest(cameraFps)
var cameraHighSpeed by scrcpyOptions.cameraHighSpeed.asMutableState()
var audioSource by scrcpyOptions.audioSource.asMutableState()
val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second }
val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst {
it.first == audioSource
}.let { if (it >= 0) it else 0 }
val audioSourceItems = remember {
Shared.AudioSource.entries.map { it.string }
}
val audioSourceIndex = remember(audioSource) {
Shared.AudioSource.entries.indexOfFirst { it.string == audioSource }.coerceAtLeast(0)
}
var audioDup by scrcpyOptions.audioDup.asMutableState()
var audioPlayback by scrcpyOptions.audioPlayback.asMutableState()
var requireAudio by scrcpyOptions.requireAudio.asMutableState()
var maxSize by scrcpyOptions.maxSize.asMutableState()
val maxSizePresetIndex = presetIndexFromInputForAdvancedPage(
maxSize.toString(), ScrcpyPresets.MaxSize
)
val maxSizePresetIndex = ScrcpyPresets.MaxSize.indexOfOrNearest(maxSize)
var maxFps by scrcpyOptions.maxFps.asMutableState()
val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(
maxFps, ScrcpyPresets.MaxFPS
)
val maxFpsPresetIndex = ScrcpyPresets.MaxFPS.indexOfOrNearest(maxFps.toIntOrNull() ?: 0)
var videoEncoder by scrcpyOptions.videoEncoder.asMutableState()
var videoCodecOptions by scrcpyOptions.videoCodecOptions.asMutableState()
var audioEncoder by scrcpyOptions.audioEncoder.asMutableState()
var audioCodecOptions by scrcpyOptions.audioCodecOptions.asMutableState()
var newDisplayWidth by remember {
mutableStateOf("")
val videoEncoderDropdownItems = remember(scrcpy.videoEncoders) {
listOf("") + scrcpy.videoEncoders
}
var newDisplayHeight by remember {
mutableStateOf("")
val videoEncoderIndex = remember(videoEncoder, scrcpy.videoEncoders) {
(scrcpy.videoEncoders.indexOf(videoEncoder) + 1).coerceAtLeast(0)
}
var newDisplayDpi by remember {
mutableStateOf("")
val audioEncoderDropdownItems = remember(scrcpy.audioEncoders) {
listOf("") + scrcpy.audioEncoders
}
val audioEncoderIndex = remember(audioEncoder, scrcpy.audioEncoders) {
(scrcpy.audioEncoders.indexOf(audioEncoder) + 1).coerceAtLeast(0)
}
val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName ->
if (encoderName == "") {
SpinnerEntry(title = "自动")
} else {
SpinnerEntry(
title = encoderName,
summary = scrcpy.videoEncoderTypes[encoderName],
)
}
}
val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName ->
if (encoderName == "") {
SpinnerEntry(title = "自动")
} else {
SpinnerEntry(
title = encoderName,
summary = scrcpy.audioEncoderTypes[encoderName],
)
}
}
// [<width>x<height>][/<dpi>]
// TODO: 填充当前值到输入框
var newDisplay by scrcpyOptions.newDisplay.asMutableState()
val (width, height, dpi) = NewDisplay.parseFrom(newDisplay)
var newDisplayWidth by remember(newDisplay) { mutableStateOf(width?.toString() ?: "") }
var newDisplayHeight by remember(newDisplay) { mutableStateOf(height?.toString() ?: "") }
var newDisplayDpi by remember(newDisplay) { mutableStateOf(dpi?.toString() ?: "") }
fun updateNewDisplay() {
var nd = ""
if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) {
nd += "${newDisplayWidth}x${newDisplayHeight}"
}
if (newDisplayDpi.isNotBlank()) {
nd += "/$newDisplayDpi"
}
newDisplay = nd
newDisplay = NewDisplay
.parseFrom(newDisplayWidth, newDisplayHeight, newDisplayDpi)
.toString()
}
var cropWidth by remember {
mutableStateOf("")
}
var cropHeight by remember {
mutableStateOf("")
}
var cropX by remember {
mutableStateOf("")
}
var cropY by remember {
mutableStateOf("")
}
// width:height:x:y
// TODO: 填充当前值到输入框
var crop by scrcpyOptions.crop.asMutableState()
fun updateCrop(): Unit {
if (cropWidth.isNotBlank()
&& cropHeight.isNotBlank()
&& cropX.isNotBlank()
&& cropY.isNotBlank()
) crop = "$cropWidth:$cropHeight:$cropX:$cropY"
val (cWidth, cHeight, cX, cY) = Crop.parseFrom(crop)
var cropWidth by remember(crop) { mutableStateOf(cWidth?.toString() ?: "") }
var cropHeight by remember(crop) { mutableStateOf(cHeight?.toString() ?: "") }
var cropX by remember(crop) { mutableStateOf(cX?.toString() ?: "") }
var cropY by remember(crop) { mutableStateOf(cY?.toString() ?: "") }
fun updateCrop() {
crop = Crop
.parseFrom(cropWidth, cropHeight, cropX, cropY)
.toString()
}
var serverParamsPreview by rememberSaveable {
mutableStateOf(runBlocking {
scrcpyOptions
.toClientOptions()
.toServerParams(0u)
.toList(simplify = true)
.joinToString(ServerParams.SEPARATOR)
})
}
// 监听所有选项变化,自动更新 serverParams 预览
LaunchedEffect(
turnScreenOff, control, video,
videoSource, displayId,
cameraId, cameraFacing, cameraSize, cameraAr, cameraFps, cameraHighSpeed,
audioSource, audioDup, audioPlayback, requireAudio,
maxSize, maxFps,
videoEncoder, videoCodecOptions,
audioEncoder, audioCodecOptions,
newDisplay, crop,
) {
val clientOptions = scrcpyOptions.toClientOptions()
try {
clientOptions.validate()
} catch (e: IllegalArgumentException) {
snackbarHostState.showSnackbar("Invalid options: ${e.message}")
return@LaunchedEffect
}
serverParamsPreview = clientOptions
.toServerParams(0u)
.toList(simplify = true)
.joinToString(ServerParams.SEPARATOR)
}
// 高级参数
AppPageLazyColumn(
@@ -227,6 +237,15 @@ internal fun AdvancedConfigPage(
scrollBehavior = scrollBehavior,
) {
item {
Card {
TextField(
value = serverParamsPreview,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
)
}
Card {
SuperSwitch(
title = "启动后关闭屏幕",
@@ -265,13 +284,13 @@ internal fun AdvancedConfigPage(
items = videoSourceItems,
selectedIndex = videoSourceIndex,
onSelectedIndexChange = {
videoSource = VIDEO_SOURCE_OPTIONS[it].first
videoSource = Shared.VideoSource.entries[it].string
},
)
if (videoSource == "display") {
AnimatedVisibility(videoSource == "display") {
TextField(
value = displayId.toString(),
onValueChange = { displayId = it.toInt() },
value = if (displayId == -1) "" else displayId.toString(),
onValueChange = { displayId = it.toIntOrNull() ?: -1 },
label = "--display-id",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@@ -281,7 +300,7 @@ internal fun AdvancedConfigPage(
.padding(bottom = UiSpacing.CardContent),
)
}
if (videoSource == "camera") {
AnimatedVisibility(videoSource == "camera") {
TextField(
value = cameraId,
onValueChange = { cameraId = it },
@@ -292,47 +311,95 @@ internal fun AdvancedConfigPage(
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
AnimatedVisibility(videoSource == "camera") {
SuperArrow(
title = "重新获取 Camera Sizes",
summary = "--list-camera-sizes",
onClick = onRefreshCameraSizes,
onClick = {
if (refreshBusy) return@SuperArrow
scope.launch {
refreshBusy = true
try {
scrcpy.refreshCameraSizes()
snackbarHostState.showSnackbar("Camera Sizes 已刷新")
} catch (e: Exception) {
snackbarHostState.showSnackbar("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
)
}
AnimatedVisibility(videoSource == "camera") {
SuperDropdown(
title = "摄像头朝向",
summary = "--camera-facing",
items = cameraFacingItems,
selectedIndex = cameraFacingIndex,
onSelectedIndexChange = {
cameraFacing = CAMERA_FACING_OPTIONS[it].first
cameraFacing =
if (it == 0) "" else Shared.CameraFacing.entries[it].string
},
)
}
AnimatedVisibility(videoSource == "camera") {
SuperDropdown(
title = "摄像头分辨率",
summary = "--camera-size",
items = cameraSizeDropdownItems,
selectedIndex = cameraSizeIndex.coerceIn(
0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0)
),
selectedIndex = cameraSizeDropdownIndex,
onSelectedIndexChange = {
cameraSize = when (it) {
0 -> ""
cameraSizeDropdownItems.lastIndex -> "custom"
else -> cameraSizeDropdownItems[it]
cameraSizeDropdownIndex = it
cameraSizeUseCustom = it == 1
when (it) {
0 -> {
// "自动"
cameraSize = ""
cameraSizeCustomInput = ""
}
1 -> {
// "自定义" - 进入自定义输入模式
cameraSizeCustomInput = cameraSize.takeIf { size ->
size.isNotEmpty() && size !in scrcpy.cameraSizes
} ?: ""
}
else -> {
// 选择列表中的实际分辨率
cameraSize = cameraSizeDropdownItems[it]
cameraSizeCustomInput = ""
}
}
},
)
if (cameraSize == "custom") {
TextField(
value = cameraSize,
onValueChange = { cameraSize = it },
label = "--camera-size",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
}
// 只在选择"自定义"时显示输入框
AnimatedVisibility(videoSource == "camera" && cameraSizeUseCustom) {
SuperTextField(
value = cameraSizeCustomInput,
onValueChange = { cameraSizeCustomInput = it },
onFocusLost = {
if (cameraSizeCustomInput in scrcpy.cameraSizes) {
cameraSizeDropdownIndex =
scrcpy.cameraSizes.indexOf(cameraSizeCustomInput) + 2
cameraSizeUseCustom = false
} else {
cameraSizeCustom = cameraSizeCustomInput
}
},
label = "--camera-size",
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
AnimatedVisibility(videoSource == "camera") {
TextField(
value = cameraAr,
onValueChange = { cameraAr = it },
@@ -343,28 +410,37 @@ internal fun AdvancedConfigPage(
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperSlide(
}
AnimatedVisibility(videoSource == "camera") {
SuperSlider(
title = "摄像头帧率",
summary = "--camera-fps",
value = cameraFpsPresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex)
cameraFps = CAMERA_FPS_PRESETS[idx]
val idx =
value.roundToInt().coerceIn(0, ScrcpyPresets.CameraFps.lastIndex)
cameraFps = ScrcpyPresets.CameraFps[idx]
},
valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(),
steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0),
valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(),
steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0),
unit = "fps",
zeroStateText = "默认",
showUnitWhenZeroState = false,
showKeyPoints = true,
keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() },
keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() },
displayText = cameraFps.toString(),
inputHint = "0 或留空表示默认",
inputInitialValue = cameraFps.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = { cameraFps = it.toInt() },
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { input ->
input.toIntOrNull()
?.let { cameraFps = it }
?: run { cameraFps = 0 }
},
)
}
AnimatedVisibility(videoSource == "camera") {
SuperSwitch(
title = "高帧率模式",
summary = "--camera-high-speed",
@@ -382,20 +458,10 @@ internal fun AdvancedConfigPage(
summary = "--audio-source",
items = audioSourceItems,
selectedIndex = audioSourceIndex,
onSelectedIndexChange = { audioSource = AUDIO_SOURCE_OPTIONS[it].first },
onSelectedIndexChange = {
audioSource = Shared.AudioSource.entries[it].string
},
)
if (audioSource == "custom") {
TextField(
value = audioSource,
onValueChange = { audioSource = it },
label = "--audio-source",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
SuperSwitch(
title = "音频双路输出",
summary = "--audio-dup",
@@ -420,12 +486,13 @@ internal fun AdvancedConfigPage(
item {
Card {
SuperSlide(
SuperSlider(
title = "最大分辨率",
summary = "--max-size",
value = maxSizePresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex)
val idx =
value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex)
maxSize = ScrcpyPresets.MaxSize[idx]
},
valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(),
@@ -439,16 +506,16 @@ internal fun AdvancedConfigPage(
inputHint = "0 或留空表示关闭",
inputInitialValue = maxSize.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = { maxSize = it.toInt() },
inputValueRange = 0f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { input -> input.toIntOrNull()?.let { maxSize = it } },
)
SuperSlide(
SuperSlider(
title = "最大帧率",
summary = "--max-fps",
value = maxFpsPresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex)
maxFps = ScrcpyPresets.MaxFPS[idx].toString()
maxFps = if (idx == 0) "" else ScrcpyPresets.MaxFPS[idx].toString()
},
valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0),
@@ -461,7 +528,7 @@ internal fun AdvancedConfigPage(
inputHint = "0 或留空表示关闭",
inputInitialValue = maxFps,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { maxFps = it },
)
}
@@ -472,14 +539,29 @@ internal fun AdvancedConfigPage(
SuperArrow(
title = "重新获取编码器列表",
summary = "--list-encoders",
onClick = onRefreshEncoders,
onClick = {
if (refreshBusy) return@SuperArrow
scope.launch {
refreshBusy = true
try {
scrcpy.refreshEncoders()
snackbarHostState.showSnackbar("编码器列表已刷新")
} catch (e: Exception) {
snackbarHostState.showSnackbar("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
)
SuperSpinner(
title = "视频编码器",
summary = "--video-encoder",
items = videoEncoderEntries,
selectedIndex = videoEncoderIndex,
onSelectedIndexChange = { videoEncoder = videoEncoderEntries[it].title ?: "" },
onSelectedIndexChange = {
videoEncoder = videoEncoderEntries[it].title ?: ""
},
)
TextField(
value = videoCodecOptions,
@@ -496,7 +578,9 @@ internal fun AdvancedConfigPage(
summary = "--audio-encoder",
items = audioEncoderEntries,
selectedIndex = audioEncoderIndex,
onSelectedIndexChange = { audioEncoder = audioEncoderEntries[it].title ?: "" },
onSelectedIndexChange = {
audioEncoder = audioEncoderEntries[it].title ?: ""
},
)
TextField(
value = audioCodecOptions,
@@ -531,9 +615,9 @@ internal fun AdvancedConfigPage(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
label = "width",
value = newDisplayWidth,
onValueChange = { newDisplayWidth = it; updateNewDisplay() },
label = "width",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -545,9 +629,9 @@ internal fun AdvancedConfigPage(
modifier = Modifier.weight(1f),
)
TextField(
label = "height",
value = newDisplayHeight,
onValueChange = { newDisplayHeight = it; updateNewDisplay() },
label = "height",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -559,9 +643,9 @@ internal fun AdvancedConfigPage(
modifier = Modifier.weight(1f),
)
TextField(
label = "dpi",
value = newDisplayDpi,
onValueChange = { newDisplayDpi = it; updateNewDisplay() },
label = "dpi",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -600,9 +684,9 @@ internal fun AdvancedConfigPage(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
value = cropWidth,
onValueChange = { cropWidth = it },
label = "width",
value = cropWidth,
onValueChange = { cropWidth = it; updateCrop() },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -614,9 +698,9 @@ internal fun AdvancedConfigPage(
modifier = Modifier.weight(1f),
)
TextField(
value = cropHeight,
onValueChange = { cropHeight = it },
label = "height",
value = cropHeight,
onValueChange = { cropHeight = it; updateCrop() },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -633,9 +717,9 @@ internal fun AdvancedConfigPage(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
value = cropX,
onValueChange = { cropX = it },
label = "x",
value = cropX,
onValueChange = { cropX = it; updateCrop() },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -647,9 +731,9 @@ internal fun AdvancedConfigPage(
modifier = Modifier.weight(1f),
)
TextField(
value = cropY,
onValueChange = { cropY = it },
label = "y",
value = cropY,
onValueChange = { cropY = it; updateCrop() },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
@@ -669,27 +753,3 @@ internal fun AdvancedConfigPage(
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
}
}
private fun presetIndexFromInputForAdvancedPage(raw: Int, presets: List<Int>): Int {
val exact = presets.indexOf(raw)
if (exact >= 0) return exact
val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - raw) }
return nearest?.index ?: 0
}
private fun presetIndexFromInputForAdvancedPage(raw: String, presets: List<Int>): Int {
if (raw.isBlank()) return 0
val value = raw.toIntOrNull() ?: return 0
val exact = presets.indexOf(value)
if (exact >= 0) return exact
val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) }
return nearest?.index ?: 0
}
private fun resolveEncoderTypeLabel(raw: String?): String {
return when (raw?.trim()?.lowercase()) {
"hw" -> "hw"
"sw" -> "sw"
else -> ""
}
}

View File

@@ -14,13 +14,11 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
@@ -30,7 +28,6 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
@@ -50,7 +47,6 @@ import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -60,47 +56,14 @@ import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
import java.net.InetSocketAddress
import java.net.Socket
import java.util.concurrent.Executors
private const val ADB_CONNECT_TIMEOUT_MS = 3_000L
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L
private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 2_000L
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 2_000L
private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F"
private val DeviceShortcutsSaver =
listSaver<DeviceShortcuts, String>(
save = { shortcuts ->
// 将 DeviceShortcuts 中的每个设备转换为一行字符串
shortcuts.devices.map { device ->
listOf(
device.id,
device.name,
device.host,
device.port.toString(),
if (device.online) "1" else "0"
).joinToString(DEVICE_SHORTCUT_SEPARATOR)
}
},
restore = { saved ->
// 从保存的字符串列表恢复 DeviceShortcuts
DeviceShortcuts(saved.mapNotNull { line ->
val parts = line.split(DEVICE_SHORTCUT_SEPARATOR)
if (parts.size != 5) return@mapNotNull null
val port = parts[3].toIntOrNull() ?: return@mapNotNull null
DeviceShortcut(
name = parts[1],
host = parts[2],
port = port,
online = parts[4] == "1"
)
}.toMutableList())
},
)
@Composable
fun DeviceTabScreen(
contentPadding: PaddingValues,
@@ -109,23 +72,12 @@ fun DeviceTabScreen(
scrcpy: Scrcpy,
snack: SnackbarHostState,
scrollBehavior: ScrollBehavior,
videoEncoderOptions: List<String>,
onVideoEncoderOptionsChange: (List<String>) -> Unit,
onVideoEncoderTypeMapChange: (Map<String, String>) -> Unit,
audioEncoderOptions: List<String>,
onAudioEncoderOptionsChange: (List<String>) -> Unit,
onAudioEncoderTypeMapChange: (Map<String, String>) -> Unit,
cameraSizeOptions: List<String>,
onCameraSizeOptionsChange: (List<String>) -> Unit,
onSessionStartedChange: (Boolean) -> Unit,
onRefreshEncodersActionChange: ((() -> Unit)?) -> Unit,
onRefreshCameraSizesActionChange: ((() -> Unit)?) -> Unit,
onClearLogsActionChange: ((() -> Unit)?) -> Unit,
onClearLogsActionChange: ((() -> Unit)) -> Unit,
onCanClearLogsChange: (Boolean) -> Unit,
onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit,
onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit,
onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit,
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
) {
val appSettings = Storage.appSettings
val quickDevices = Storage.quickDevices
@@ -141,24 +93,14 @@ fun DeviceTabScreen(
}
// val initialSettings = remember(context) { loadDevicePageSettings(context) }
val scope = rememberCoroutineScope()
val adbWorkerDispatcher = remember {
Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "adb-connect-worker").apply { isDaemon = true }
}.asCoroutineDispatcher()
}
// Run adb operations on a dedicated single thread.
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
DisposableEffect(adbWorkerDispatcher) {
onDispose {
adbWorkerDispatcher.close()
}
}
var busy by rememberSaveable { mutableStateOf(false) }
var statusLine by rememberSaveable { mutableStateOf("未连接") }
var adbConnected by rememberSaveable { mutableStateOf(false) }
var isQuickConnected by rememberSaveable { mutableStateOf(false) }
var currentTargetHost by rememberSaveable { mutableStateOf("") }
var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) }
var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") }
@@ -168,7 +110,7 @@ fun DeviceTabScreen(
var sessionInfoCodec by rememberSaveable { mutableStateOf("") }
var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) }
var sessionInfo by remember {
mutableStateOf<ScrcpySessionInfo?>(null)
mutableStateOf<Scrcpy.Session.SessionInfo?>(null)
}
LaunchedEffect(
sessionInfoWidth,
@@ -178,12 +120,16 @@ fun DeviceTabScreen(
sessionInfoControlEnabled
) {
sessionInfo = if (sessionInfoDeviceName.isNotBlank()) {
ScrcpySessionInfo(
Scrcpy.Session.SessionInfo(
width = sessionInfoWidth,
height = sessionInfoHeight,
deviceName = sessionInfoDeviceName,
codec = sessionInfoCodec,
codecId = 0,
codecName = sessionInfoCodec,
audioCodecId = 0,
controlEnabled = sessionInfoControlEnabled,
host = currentTargetHost,
port = currentTargetPort,
)
} else {
null
@@ -202,7 +148,7 @@ fun DeviceTabScreen(
if (currentTargetHost.isNotBlank())
ConnectionTarget(
currentTargetHost,
currentTargetPort
currentTargetPort,
) else null
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
@@ -212,11 +158,11 @@ fun DeviceTabScreen(
}
var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
var savedShortcuts = rememberSaveable(quickDevicesList, saver = DeviceShortcutsSaver) {
DeviceShortcuts.unmarshalFrom(quickDevicesList)
}
var quickConnectInput by quickDevices.quickConnectInput.asMutableState()
var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) }
LaunchedEffect(quickDevicesList) {
savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList)
}
// save changes when [savedShortcuts] was modified
LaunchedEffect(savedShortcuts) {
val serialized = savedShortcuts.marshalToString()
@@ -225,15 +171,18 @@ fun DeviceTabScreen(
}
}
var quickConnectInput by quickDevices.quickConnectInput.asMutableState()
var quickConnectInputTemp by remember(quickConnectInput) { mutableStateOf(quickConnectInput) }
/**
* Disconnect the current ADB connection and stop any running scrcpy session.
*
* Concurrency / thread boundary:
* - Native calls that may block are executed on [adbWorkerDispatcher] using [withContext].
* - Native calls that may block are executed on ADB dispatcher using adbService.withAdbDispatcher.
* - This ensures UI coroutines are never blocked by synchronous native I/O.
*
* Side effects:
* - Calls `nativeCore.scrcpyStop()` and `nativeCore.adbDisconnect()` (best-effort).
* - Calls `scrcpy.stop()` and `adbService.disconnect()` (best-effort).
* - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`,
* `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`.
* - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided.
@@ -252,11 +201,9 @@ fun DeviceTabScreen(
logMessage: String? = null,
showSnackMessage: String? = null,
) {
withContext(adbWorkerDispatcher) {
// Also stops scrcpy.
runCatching { scrcpy.stop() }
runCatching { adbService.disconnect() }
}
// Also stops scrcpy.
runCatching { scrcpy.stop() }
runCatching { adbService.disconnect() }
adbConnected = false
currentTargetHost = ""
currentTargetPort = Defaults.ADB_PORT
@@ -326,10 +273,8 @@ fun DeviceTabScreen(
* indefinitely. Use a small, caller-chosen timeout to keep UX snappy.
*/
suspend fun connectWithTimeout(host: String, port: Int) {
return withContext(adbWorkerDispatcher) {
withTimeout(ADB_CONNECT_TIMEOUT_MS) {
adbService.connect(host, port)
}
return withTimeout(ADB_CONNECT_TIMEOUT_MS) {
adbService.connect(host, port)
}
}
@@ -347,17 +292,12 @@ fun DeviceTabScreen(
* echo check helps detect that state.
*/
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
return withContext(adbWorkerDispatcher) {
withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
val connected = adbService.isConnected()
if (!connected) {
return@withTimeout false
}
runCatching {
adbService.shell("echo -n 1")
true
}.getOrElse { false }
return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
val connected = adbService.isConnected()
if (!connected) {
return@withTimeout false
}
return@withTimeout true
}
}
@@ -391,7 +331,7 @@ fun DeviceTabScreen(
fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
// For non-adb actions (start/stop/pair/list refresh...).
if (busy) return
scope.launch {
scope.launch(kotlinx.coroutines.Dispatchers.IO) {
busy = true
try {
block()
@@ -425,10 +365,16 @@ fun DeviceTabScreen(
* UI controls remain actionable while background retries occur.
* - Errors and timeouts are logged and surfaced similarly to `runBusy`.
*/
fun runAdbConnect(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
fun runAdbConnect(
label: String,
onStarted: (() -> Unit)? = null,
onFinished: (() -> Unit)? = null,
block: suspend () -> Unit,
) {
// For manual adb operations from user actions.
if (adbConnecting) return
scope.launch {
onStarted?.invoke()
adbConnecting = true
try {
block()
@@ -465,35 +411,23 @@ fun DeviceTabScreen(
suspend fun refreshEncoderLists() {
if (!adbConnected) return
runCatching {
scrcpy.listOptions(list = ListOptions.ENCODERS)
}.onSuccess { result ->
val lists = result as Scrcpy.ListResult.Encoders
onVideoEncoderOptionsChange(lists.videoEncoders)
onAudioEncoderOptionsChange(lists.audioEncoders)
onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
onAudioEncoderTypeMapChange(lists.audioEncoderTypes)
if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) {
scrcpy.refreshEncoders()
}.onSuccess {
// Validate current selections
if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) {
videoEncoder = ""
}
if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) {
if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) {
audioEncoder = ""
}
logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}")
if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) {
logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}")
if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) {
logEvent(
"提示: 编码器为空,请检查 server 路径/版本与设备系统日志",
Log.WARN
)
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
if (preview.isNotBlank()) {
logEvent("编码器原始输出: $preview", Log.DEBUG)
}
}
}.onFailure { e ->
onVideoEncoderOptionsChange(emptyList())
onAudioEncoderOptionsChange(emptyList())
onVideoEncoderTypeMapChange(emptyMap())
onAudioEncoderTypeMapChange(emptyMap())
logEvent(
"读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}",
Log.ERROR,
@@ -507,22 +441,14 @@ fun DeviceTabScreen(
suspend fun refreshCameraSizeLists() {
if (!adbConnected) return
runCatching {
scrcpy.listOptions(ListOptions.CAMERA_SIZES)
}.onSuccess { result ->
val lists = result as Scrcpy.ListResult.CameraSizes
onCameraSizeOptionsChange(lists.sizes)
if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) {
scrcpy.refreshCameraSizes()
}.onSuccess {
// Validate current selection
if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) {
cameraSize = ""
}
logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
if (lists.sizes.isEmpty()) {
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
if (preview.isNotBlank()) {
logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
}
}
logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}")
}.onFailure { e ->
onCameraSizeOptionsChange(emptyList())
logEvent(
"读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}",
Log.ERROR,
@@ -738,7 +664,7 @@ fun DeviceTabScreen(
sessionInfoWidth = sessionInfo?.width ?: 0
sessionInfoHeight = sessionInfo?.height ?: 0
sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty()
sessionInfoCodec = sessionInfo?.codec.orEmpty()
sessionInfoCodec = sessionInfo?.codecName.orEmpty()
sessionInfoControlEnabled = sessionInfo?.controlEnabled == true
} else {
sessionInfoWidth = 0
@@ -751,12 +677,6 @@ fun DeviceTabScreen(
}
DisposableEffect(Unit) {
onRefreshEncodersActionChange {
runBusy("刷新编码器") { refreshEncoderLists() }
}
onRefreshCameraSizesActionChange {
runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() }
}
onClearLogsActionChange {
EventLogger.clearLogs()
}
@@ -764,11 +684,8 @@ fun DeviceTabScreen(
showReorderSheet = true
}
onDispose {
onRefreshEncodersActionChange(null)
onRefreshCameraSizesActionChange(null)
onClearLogsActionChange(null)
// canClearLogs 是 DevicePage 特有的状态,需要重置
onCanClearLogsChange(false)
onOpenReorderDevicesActionChange(null)
}
}
@@ -800,8 +717,8 @@ fun DeviceTabScreen(
fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") {
nativeCore.sessionManager.injectKeycode(0, keycode)
nativeCore.sessionManager.injectKeycode(1, keycode)
nativeCore.session?.injectKeycode(0, keycode)
nativeCore.session?.injectKeycode(1, keycode)
}
}
@@ -870,14 +787,19 @@ fun DeviceTabScreen(
onAction = {
haptics.contextClick()
if (!isConnectedTarget) {
activeDeviceActionId = device.id
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
runAdbConnect(
"连接 ADB",
onStarted = { activeDeviceActionId = device.id },
onFinished = { activeDeviceActionId = null },
) {
disconnectCurrentTargetBeforeConnecting(host, port)
try {
connectWithTimeout(host, port)
adbConnected = true
savedShortcuts =
savedShortcuts.update(host = host, port = port, online = true)
isQuickConnected = false // 标记为快速设备连接
savedShortcuts = savedShortcuts.update(
host = host, port = port, online = true
)
handleAdbConnected(host, port)
} catch (e: Exception) {
statusLine = "ADB 连接失败"
@@ -889,7 +811,11 @@ fun DeviceTabScreen(
}
} else {
activeDeviceActionId = device.id
runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) {
runAdbConnect(
"断开 ADB",
onStarted = { activeDeviceActionId = device.id },
onFinished = { activeDeviceActionId = null },
) {
sessionReconnectBlacklistHosts += host
disconnectAdbConnection(
clearQuickOnlineForTarget = ConnectionTarget(host, port),
@@ -905,28 +831,37 @@ fun DeviceTabScreen(
if (!adbConnected) item {
// "快速连接"
QuickConnectCard(
input = quickConnectInput,
onValueChange = { quickConnectInput = it },
input = quickConnectInputTemp,
onValueChange = {
quickConnectInputTemp = it
quickConnectInput = quickConnectInputTemp
},
// onFocusChange = { quickConnectInput = quickConnectInputTemp },
enabled = !adbConnecting,
onAddDevice = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInput)
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
?: return@QuickConnectCard
savedShortcuts = savedShortcuts.upsert(
DeviceShortcut(host = target.host, port = target.port)
)
Log.i("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList")
Log.d("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList")
scope.launch {
snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
}
},
onConnect = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInput)
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
?: return@QuickConnectCard
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
runAdbConnect(
"连接 ADB",
onStarted = { activeDeviceActionId = target.toString() },
onFinished = { activeDeviceActionId = null },
) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try {
connectWithTimeout(target.host, target.port)
adbConnected = true
isQuickConnected = true // 标记为快速连接
savedShortcuts = savedShortcuts.update(
host = target.host,
port = target.port,
@@ -956,7 +891,7 @@ fun DeviceTabScreen(
onPair = { host, port, code ->
runBusy("执行配对") {
val resolvedHost = host.trim()
val resolvedPort = port.toIntOrNull() ?: return@runBusy
val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim()
val ok = adbService.pair(
resolvedHost,
@@ -980,26 +915,30 @@ fun DeviceTabScreen(
ConfigPanel(
busy = busy,
audioForwardingSupported = audioForwardingSupported,
cameraMirroringSupported = cameraMirroringSupported,
onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.contextClick() },
onStart = {
runBusy("启动 scrcpy") {
val options = scrcpyOptions.toClientOptions()
val session = scrcpy.start(options)
sessionInfo = session
sessionInfo = session.copy(
host = currentTargetHost,
port = currentTargetPort
)
statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale")
val videoDetail = if (!options.video) {
"off"
} else {
"${session.codec} ${session.width}x${session.height} " +
"@${String.format("%.1f", videoBitRate / 1_000_000)}Mbps"
"${session.codecName} ${session.width}x${session.height} " +
"@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
}
val audioDetail = if (!audio) {
"off"
} else {
val playback = if (!options.audioPlayback) "(no-playback)" else ""
"${options.audioCodec} ${videoBitRate / 1_000}Kbps source=${options.audioSource}$playback"
"${options.audioCodec} ${videoBitRate / 1_000f}Kbps source=${options.audioSource}$playback"
}
logEvent(
"scrcpy 已启动: device=${session.deviceName}" +
@@ -1010,9 +949,6 @@ fun DeviceTabScreen(
scope.launch {
snack.showSnackbar("scrcpy 已启动")
}
scrcpy.getLastServerCommand()?.let { command ->
logEvent("scrcpy-server args: $command")
}
}
},
onStop = {
@@ -1026,7 +962,23 @@ fun DeviceTabScreen(
}
}
},
sessionStarted = sessionInfo != null,
sessionInfo = sessionInfo,
onDisconnect = {
runAdbConnect(
"断开 ADB",
onStarted = {},
onFinished = {},
) {
currentTarget?.let { target ->
sessionReconnectBlacklistHosts += target.host
disconnectAdbConnection(
clearQuickOnlineForTarget = target,
logMessage = "ADB 已断开",
showSnackMessage = "ADB 已断开",
)
}
}
},
)
}
@@ -1068,7 +1020,6 @@ fun DeviceTabScreen(
LogsPanel(lines = EventLogger.eventLog)
}
// TODO: 放进 [AppPageLazyColumn] 里
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
}
}

View File

@@ -20,8 +20,8 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -68,11 +68,13 @@ fun FullscreenControlPage(
}
var session by remember(launch) {
mutableStateOf(
ScrcpySessionInfo(
Scrcpy.Session.SessionInfo(
width = launch.width,
height = launch.height,
deviceName = launch.deviceName.ifBlank { "设备" },
codec = launch.codec.ifBlank { "unknown" },
codecId = 0,
codecName = launch.codec.ifBlank { "unknown" },
audioCodecId = 0,
controlEnabled = true,
),
)
@@ -121,8 +123,12 @@ fun FullscreenControlPage(
}
suspend fun sendKeycode(keycode: Int) {
nativeCore.sessionManager.injectKeycode(0, keycode)
nativeCore.sessionManager.injectKeycode(1, keycode)
runCatching {
nativeCore.session?.injectKeycode(0, keycode)
nativeCore.session?.injectKeycode(1, keycode)
}.onFailure { e ->
android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e)
}
}
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
@@ -139,7 +145,7 @@ fun FullscreenControlPage(
currentFps = currentFps,
enableBackHandler = false,
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
nativeCore.sessionManager.injectTouch(
nativeCore.session?.injectTouch(
action = action,
pointerId = pointerId,
x = x,

View File

@@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -93,6 +92,7 @@ private sealed interface RootScreen : NavKey {
@Composable
fun MainPage() {
val context = LocalContext.current
val appContext = context.applicationContext
val scope = rememberCoroutineScope()
val activity = remember(context) { context as? Activity }
@@ -105,9 +105,9 @@ fun MainPage() {
}
}
val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) }
val adbService = remember(context) { NativeAdbService(context) }
val scrcpy = remember(context) { Scrcpy(context) }
val nativeCore = remember(appContext) { NativeCoreFacade.get(appContext) }
val adbService = remember(appContext) { NativeAdbService(appContext) }
val scrcpy = remember(appContext, adbService) { Scrcpy(appContext, adbService) }
val snackHostState = remember { SnackbarHostState() }
val saveableStateHolder = rememberSaveableStateHolder()
@@ -194,16 +194,9 @@ fun MainPage() {
val appSettings = Storage.appSettings
val scrcpyOptions = Storage.scrcpyOptions
val videoEncoderOptions = remember { mutableStateListOf<String>() }
val audioEncoderOptions = remember { mutableStateListOf<String>() }
val videoEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
val audioEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
val cameraSizeOptions = remember { mutableStateListOf<String>() }
var sessionStarted by remember { mutableStateOf(false) }
var refreshEncodersAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var refreshCameraSizesAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var clearLogsAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var openReorderDevicesAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var clearLogsAction by remember { mutableStateOf({}) }
var openReorderDevicesAction by remember { mutableStateOf({}) }
var canClearLogs by remember { mutableStateOf(false) }
var showDeviceMenu by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable {
@@ -314,7 +307,7 @@ fun MainPage() {
canClearLogs = canClearLogs,
onDismissRequest = { showDeviceMenu = false },
onReorderDevices = {
openReorderDevicesAction?.invoke()
openReorderDevicesAction()
showDeviceMenu = false
},
onOpenVirtualButtonOrder = {
@@ -322,7 +315,7 @@ fun MainPage() {
showDeviceMenu = false
},
onClearLogs = {
clearLogsAction?.invoke()
clearLogsAction()
showDeviceMenu = false
},
)
@@ -338,34 +331,7 @@ fun MainPage() {
scrcpy = scrcpy,
snack = snackHostState,
scrollBehavior = devicesPageScrollBehavior,
videoEncoderOptions = videoEncoderOptions,
onVideoEncoderOptionsChange = {
videoEncoderOptions.clear()
videoEncoderOptions.addAll(it)
},
onVideoEncoderTypeMapChange = {
videoEncoderTypeMap.clear()
videoEncoderTypeMap.putAll(it)
},
audioEncoderOptions = audioEncoderOptions,
onAudioEncoderOptionsChange = {
audioEncoderOptions.clear()
audioEncoderOptions.addAll(it)
},
onAudioEncoderTypeMapChange = {
audioEncoderTypeMap.clear()
audioEncoderTypeMap.putAll(it)
},
cameraSizeOptions = cameraSizeOptions,
onCameraSizeOptionsChange = {
cameraSizeOptions.clear()
cameraSizeOptions.addAll(it)
},
onSessionStartedChange = { sessionStarted = it },
onRefreshEncodersActionChange = { refreshEncodersAction = it },
onRefreshCameraSizesActionChange = {
refreshCameraSizesAction = it
},
onClearLogsActionChange = { clearLogsAction = it },
onCanClearLogsChange = { canClearLogs = it },
onOpenReorderDevicesActionChange = {
@@ -385,7 +351,7 @@ fun MainPage() {
deviceName = session.deviceName,
width = session.width,
height = session.height,
codec = session.codec,
codec = session.codecName,
),
),
)
@@ -404,7 +370,7 @@ fun MainPage() {
SettingsScreen(
contentPadding = pagePadding,
onOpenReorderDevices = {
openReorderDevicesAction?.invoke()
openReorderDevicesAction()
},
onOpenVirtualButtonOrder = {
rootBackStack.add(RootScreen.VirtualButtonOrder)
@@ -427,14 +393,7 @@ fun MainPage() {
}
}
val videoEncoder by scrcpyOptions.videoEncoder.asState()
val audioEncoder by scrcpyOptions.audioEncoder.asState()
entry(RootScreen.Advanced) {
val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions
val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions
val videoEncoderIndex = (videoEncoderOptions.indexOf(videoEncoder) + 1).coerceAtLeast(0)
val audioEncoderIndex = (audioEncoderOptions.indexOf(audioEncoder) + 1).coerceAtLeast(0)
Scaffold(
topBar = {
TopAppBar(
@@ -456,16 +415,7 @@ fun MainPage() {
contentPadding = pagePadding,
scrollBehavior = advancedPageScrollBehavior,
snackbarHostState = snackHostState,
cameraSizeDropdownItems = listOf("默认") + cameraSizeOptions + listOf("自定义"),
cameraSizeOptions = cameraSizeOptions,
videoEncoderDropdownItems = videoEncoderDropdownItems,
videoEncoderTypeMap = videoEncoderTypeMap,
videoEncoderIndex = videoEncoderIndex,
audioEncoderDropdownItems = audioEncoderDropdownItems,
audioEncoderTypeMap = audioEncoderTypeMap,
audioEncoderIndex = audioEncoderIndex,
onRefreshEncoders = { refreshEncodersAction?.invoke() },
onRefreshCameraSizes = { refreshCameraSizesAction?.invoke() },
scrcpy = scrcpy,
)
}
}

View File

@@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
import android.os.Process
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -12,9 +13,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -22,13 +26,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
@@ -77,6 +83,10 @@ fun SettingsScreen(
) {
val appContext = LocalContext.current.applicationContext
val appSettings = Storage.appSettings
var needMigration by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
needMigration = PreferenceMigration(appContext).needsMigration()
}
val context = LocalContext.current
@@ -98,9 +108,22 @@ fun SettingsScreen(
// scrcpy-server
var customServerUri by appSettings.customServerUri.asMutableState()
var serverRemotePath by appSettings.serverRemotePath.asMutableState()
var serverRemotePathInput by rememberSaveable {
mutableStateOf(
// 默认值留空显示为 hint
if (runBlocking { appSettings.serverRemotePath.isDefaultValue() }) ""
else serverRemotePath
)
}
// ADB
var adbKeyName by appSettings.adbKeyName.asMutableState()
var adbKeyNameInput by rememberSaveable {
mutableStateOf(
if (runBlocking { appSettings.adbKeyName.isDefaultValue() }) ""
else adbKeyName
)
}
var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState()
var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState()
@@ -141,7 +164,7 @@ fun SettingsScreen(
checked = keepScreenOnWhenStreaming,
onCheckedChange = { keepScreenOnWhenStreaming = it },
)
SuperSlide(
SuperSlider(
title = "预览卡高度",
summary = "设备页预览卡高度",
value = devicePreviewCardHeightDp.toFloat(),
@@ -149,12 +172,12 @@ fun SettingsScreen(
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
},
valueRange = 160f..600f,
steps = 439,
steps = 600-160-1,
unit = "dp",
displayFormatter = { it.roundToInt().toString() },
inputInitialValue = devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..Float.MAX_VALUE,
inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { input ->
input.toIntOrNull()?.let {
devicePreviewCardHeightDp = it.coerceAtLeast(120)
@@ -218,9 +241,13 @@ fun SettingsScreen(
.padding(bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = serverRemotePath,
onValueChange = { serverRemotePath = it },
SuperTextField(
value = serverRemotePathInput,
onValueChange = { serverRemotePathInput = it },
onFocusLost = {
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
serverRemotePathInput = ""
},
label = AppSettings.SERVER_REMOTE_PATH.defaultValue,
useLabelAsPlaceholder = true,
singleLine = true,
@@ -240,9 +267,13 @@ fun SettingsScreen(
.padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = adbKeyName,
onValueChange = { adbKeyName = it },
SuperTextField(
value = adbKeyNameInput,
onValueChange = { adbKeyNameInput = it },
onFocusLost = {
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
adbKeyNameInput = ""
},
label = AppSettings.ADB_KEY_NAME.defaultValue,
useLabelAsPlaceholder = true,
singleLine = true,
@@ -265,35 +296,41 @@ fun SettingsScreen(
)
}
SectionSmallTitle("应用")
Card {
SuperArrow(
title = "恢复旧版本配置",
summary = "从旧版本的 SharedPreferences 恢复至 DataStore",
onClick = {
scope.launch {
val migration = PreferenceMigration(appContext)
migration.migrate(clearSharedPrefs = false)
snackHostState.showSnackbar("迁移完成,应用将重启")
// 这部分应该不会显示出来,
// 应用启动时就会执行迁移与旧数据的删除
AnimatedVisibility(needMigration) {
SectionSmallTitle("应用")
}
AnimatedVisibility(needMigration) {
Card {
SuperArrow(
title = "恢复旧版本配置",
summary = "从旧版本的 SharedPreferences 恢复至 DataStore",
onClick = {
scope.launch {
val migration = PreferenceMigration(appContext)
migration.migrate(clearSharedPrefs = false)
snackHostState.showSnackbar("迁移完成,应用将重启")
delay(1000)
delay(1000)
val intent = context.packageManager.getLaunchIntentForPackage(
context.packageName
)
intent?.apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
val intent = context.packageManager.getLaunchIntentForPackage(
context.packageName
)
}
context.startActivity(intent)
intent?.apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
)
}
context.startActivity(intent)
Process.killProcess(Process.myPid())
exitProcess(0)
}
},
)
Process.killProcess(Process.myPid())
exitProcess(0)
}
},
)
}
}
SectionSmallTitle("关于")
@@ -313,7 +350,6 @@ fun SettingsScreen(
}
}
// TODO: 放进 [AppPageLazyColumn] 里
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
}
}

View File

@@ -2,16 +2,17 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Slider
@@ -23,7 +24,7 @@ import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun SuperSlide(
fun SuperSlider(
title: String,
summary: String,
value: Float,
@@ -82,60 +83,71 @@ fun SuperSlide(
},
)
if (showInputDialog) {
var valueText by remember(inputInitialValue) { mutableStateOf(inputInitialValue) }
val activeInputRange = inputValueRange ?: valueRange
SuperDialog(
show = true,
onDismissRequest = {
showInputDialog = false
holdArrow = false
},
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(text = inputTitle)
}
SliderInputDialog(
showDialog = showInputDialog,
title = inputTitle,
summary = inputHint,
initialValue = inputInitialValue,
inputFilter = inputFilter,
inputValueRange = inputValueRange ?: valueRange,
onDismissRequest = { showInputDialog = false },
onDismissFinished = { holdArrow = false },
onConfirm = { input ->
onInputConfirm(input)
showInputDialog = false
},
)
}
@Composable
private fun SliderInputDialog(
showDialog: Boolean,
title: String,
summary: String,
initialValue: String,
inputFilter: (String) -> String,
inputValueRange: ClosedFloatingPointRange<Float>,
onDismissRequest: () -> Unit,
onDismissFinished: () -> Unit,
onConfirm: (String) -> Unit,
) {
SuperDialog(
show = showDialog,
title = title,
summary = summary,
onDismissRequest = onDismissRequest,
onDismissFinished = onDismissFinished,
content = {
var text by remember(initialValue) { mutableStateOf(initialValue) }
TextField(
value = valueText,
onValueChange = { valueText = inputFilter(it) },
label = inputHint,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
.padding(top = UiSpacing.Large),
modifier = Modifier.padding(bottom = 16.dp),
value = text,
maxLines = 1,
onValueChange = { newValue ->
text = inputFilter(newValue)
},
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = UiSpacing.Large),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(
text = "取消",
onClick = onDismissRequest,
modifier = Modifier.weight(1f),
onClick = {
showInputDialog = false
holdArrow = false
},
)
Spacer(Modifier.width(20.dp))
TextButton(
text = "确定",
modifier = Modifier.weight(1f),
onClick = {
val inputValue = valueText.trim().toFloatOrNull()
if (inputValue != null && inputValue >= activeInputRange.start && inputValue <= activeInputRange.endInclusive) {
onInputConfirm(valueText.trim())
showInputDialog = false
holdArrow = false
val inputValue = text.toFloatOrNull() ?: 0f
if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) {
onConfirm(text.trim())
}
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
}
},
)
}

View File

@@ -0,0 +1,121 @@
package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* A wrapped [TextField] component with state change callbacks.
*
* @param value The text to be displayed in the text field.
* @param onValueChange The callback to be called when the value changes.
* @param modifier The modifier to be applied to the [TextField].
* @param label The label to be displayed when the [TextField] is empty.
* @param enabled Whether the [TextField] is enabled.
* @param readOnly Whether the [TextField] is read-only.
* @param singleLine Whether the text field is single line.
* @param maxLines The maximum number of lines allowed to be displayed.
* @param minLines The minimum number of lines allowed to be displayed.
* @param useLabelAsPlaceholder Whether to use the label as a placeholder.
* @param keyboardOptions The keyboard options to be applied to the [TextField].
* @param keyboardActions The keyboard actions to be applied to the [TextField].
* @param visualTransformation The visual transformation to be applied to the [TextField].
* @param onFocusGained The callback to be called when the text field gains focus.
* @param onFocusLost The callback to be called when the text field loses focus.
* @param insideMargin The margin inside the [TextField].
* @param backgroundColor The background color of the [TextField].
* @param cornerRadius The corner radius of the [TextField].
* @param labelColor The color of the label.
* @param borderColor The color of the border when the [TextField] is focused.
* @param textStyle The text style to be applied to the [TextField].
* @param cursorBrush The brush to be used for the cursor.
* @param leadingIcon The leading icon to be displayed in the [TextField].
* @param trailingIcon The trailing icon to be displayed in the [TextField].
*/
@Composable
fun SuperTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String = "",
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
useLabelAsPlaceholder: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
onFocusGained: (() -> Unit)? = null,
onFocusLost: (() -> Unit)? = null,
insideMargin: DpSize = DpSize(16.dp, 16.dp),
backgroundColor: Color = MiuixTheme.colorScheme.secondaryContainer,
cornerRadius: Dp = 16.dp,
labelColor: Color = MiuixTheme.colorScheme.onSecondaryContainer,
borderColor: Color = MiuixTheme.colorScheme.primary,
textStyle: TextStyle = MiuixTheme.textStyles.main,
cursorBrush: Brush = SolidColor(MiuixTheme.colorScheme.primary),
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
) {
var isFocused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
// 监听焦点状态变化并触发回调
LaunchedEffect(isFocused) {
if (isFocused) onFocusGained?.invoke()
else onFocusLost?.invoke()
}
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
}
.focusRequester(focusRequester),
label = label,
enabled = enabled,
readOnly = readOnly,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
useLabelAsPlaceholder = useLabelAsPlaceholder,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = visualTransformation,
insideMargin = insideMargin,
backgroundColor = backgroundColor,
cornerRadius = cornerRadius,
labelColor = labelColor,
borderColor = borderColor,
textStyle = textStyle,
cursorBrush = cursorBrush,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
interactionSource = interactionSource,
)
}

View File

@@ -88,9 +88,9 @@ data class ClientOptions(
var maxSize: UShort = 0u, // to server
// --video-bit-rate
var videoBitRate: UInt = 0u, // to server
var videoBitRate: Int = 0, // to server
// --audio-bit-rate
var audioBitRate: UInt = 0u, // to server
var audioBitRate: Int = 0, // to server
// --max-fps
var maxFps: String = "", // float to be parsed by the server
@@ -115,7 +115,8 @@ data class ClientOptions(
// var windowHeight: UShort,
// --display-id
var displayId: UInt = 0u, // to server
// -1 for empty text field
var displayId: Int = -1, // to server
// var videoBuffer: Tick,
// var audioBuffer: Tick,
@@ -278,7 +279,7 @@ data class ClientOptions(
}
if (videoSource == VideoSource.CAMERA) {
if (displayId > 0u) {
if (displayId > 0) {
throw IllegalArgumentException(
"--display-id is only available with --video-source=display"
)
@@ -331,14 +332,14 @@ data class ClientOptions(
)
}
if (displayId > 0u && newDisplay.isNotBlank()) {
if (displayId > 0 && newDisplay.isNotBlank()) {
throw IllegalArgumentException(
"Cannot specify both --display-id and --new-display"
)
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED
&& displayId == 0u && newDisplay.isBlank()
&& displayId == 0 && newDisplay.isBlank()
) {
throw IllegalArgumentException(
"--display-ime-policy is only supported on a secondary display"
@@ -466,7 +467,7 @@ data class ClientOptions(
}
*/
if (control) {
if (!control) {
if (turnScreenOff) {
throw IllegalArgumentException(
"Cannot request to turn screen off if control is disabled"

View File

@@ -3,15 +3,29 @@ package io.github.miuzarte.scrcpyforandroid.scrcpy
import android.content.Context
import android.net.Uri
import android.util.Log
import android.view.KeyEvent
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.BufferedReader
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
import java.io.File
import java.io.InputStreamReader
import java.util.ArrayDeque
import kotlin.concurrent.thread
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlin.random.nextUInt
@@ -24,25 +38,25 @@ import kotlin.random.nextUInt
* - Audio playback
* - Screen control
*
* @param context Android context
* @param appContext Android context
* @param serverAsset Asset path for the default server jar
* @param customServerUri Optional custom server URI (overrides serverAsset)
* @param serverVersion Server version string
* @param serverRemotePath Remote path where server jar will be pushed on device
*/
class Scrcpy(
private val context: Context,
private val appContext: Context,
private val adbService: NativeAdbService,
private val serverAsset: String = DEFAULT_SERVER_ASSET,
private val customServerUri: String? = null,
private val serverVersion: String = "3.3.4",
private val serverRemotePath: String = DEFAULT_REMOTE_PATH,
) {
private val adbService = NativeAdbService(context)
private val sessionManager = ScrcpySessionManager(adbService)
private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(context)
private val session = Session(adbService)
private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext)
@Volatile
private var currentSession: ScrcpySessionInfo? = null
private var currentSession: Session.SessionInfo? = null
@Volatile
private var isRunning: Boolean = false
@@ -50,6 +64,19 @@ class Scrcpy(
@Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null
// Cached encoder and camera size data
private val _videoEncoders = mutableListOf<String>()
private val _audioEncoders = mutableListOf<String>()
private val _videoEncoderTypes = mutableMapOf<String, String>()
private val _audioEncoderTypes = mutableMapOf<String, String>()
private val _cameraSizes = mutableListOf<String>()
val videoEncoders: List<String> get() = _videoEncoders.toList()
val audioEncoders: List<String> get() = _audioEncoders.toList()
val videoEncoderTypes: Map<String, String> get() = _videoEncoderTypes.toMap()
val audioEncoderTypes: Map<String, String> get() = _audioEncoderTypes.toMap()
val cameraSizes: List<String> get() = _cameraSizes.toList()
companion object {
private const val TAG = "Scrcpy"
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
@@ -74,9 +101,7 @@ class Scrcpy(
}
}
suspend fun start(
options: ClientOptions,
): ScrcpySessionInfo {
suspend fun start(options: ClientOptions): Session.SessionInfo = withContext(Dispatchers.IO) {
if (isRunning) {
throw IllegalStateException("Scrcpy session is already running")
}
@@ -109,25 +134,18 @@ class Scrcpy(
if (!options.control) {
Log.w(TAG, "start(): turnScreenOff ignored because control is disabled")
} else {
runCatching { sessionManager.setDisplayPower(on = false) }
runCatching { session.setDisplayPower(on = false) }
.onFailure { e -> Log.w(TAG, "start(): set display power failed", e) }
}
}
// Create session info
val session = ScrcpySessionInfo(
width = info.width,
height = info.height,
deviceName = info.deviceName,
codec = info.codecName,
controlEnabled = info.controlEnabled,
)
currentSession = session
currentSession = info
isRunning = true
// Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) {
nativeCore.onScrcpySessionStarted(session, sessionManager)
nativeCore.onScrcpySessionStarted(info, session)
}
// Setup audio player
@@ -142,7 +160,7 @@ class Scrcpy(
)
val player = ScrcpyAudioPlayer(info.audioCodecId)
audioPlayer = player
sessionManager.attachAudioConsumer { packet ->
session.attachAudioConsumer { packet ->
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
}
} else {
@@ -150,13 +168,13 @@ class Scrcpy(
}
Log.i(
TAG, "start(): Session started successfully - device=${session.deviceName}, " +
"video=${if (options.video) "${session.codec} ${session.width}x${session.height}" else "off"}, " +
TAG, "start(): Session started successfully - device=${info.deviceName}, " +
"video=${if (options.video) "${info.codecName} ${info.width}x${info.height}" else "off"}, " +
"audio=${if (options.audio) options.audioCodec.string else "off"}, " +
"control=${options.control}"
)
return session
return@withContext info
} catch (e: Exception) {
Log.e(TAG, "start(): Failed to start scrcpy session", e)
@@ -166,19 +184,19 @@ class Scrcpy(
}
}
suspend fun stop(): Boolean {
suspend fun stop(): Boolean = withContext(Dispatchers.IO) {
if (!isRunning) {
Log.w(TAG, "stop(): No active session to stop")
return false
return@withContext false
}
Log.i(TAG, "stop(): Stopping scrcpy session")
return try {
return@withContext try {
nativeCore.onScrcpySessionStopped()
sessionManager.clearVideoConsumer()
sessionManager.clearAudioConsumer()
sessionManager.stop()
session.clearVideoConsumer()
session.clearAudioConsumer()
session.stop()
audioPlayer?.release()
audioPlayer = null
isRunning = false
@@ -196,11 +214,11 @@ class Scrcpy(
adbService.close()
}
fun isStarted(): Boolean = isRunning && sessionManager.isStarted()
fun isStarted(): Boolean = isRunning && session.isStarted()
fun getCurrentSession(): ScrcpySessionInfo? = currentSession
fun getCurrentSession(): Session.SessionInfo? = currentSession
fun getLastServerCommand(): String? = sessionManager.getLastServerCommand()
fun getLastServerCommand(): String? = session.getLastServerCommand()
sealed class ListResult {
data class Encoders(
@@ -217,13 +235,49 @@ class Scrcpy(
) : ListResult()
}
/**
* Refresh encoder lists from the device.
* Results are cached and can be accessed via videoEncoders, audioEncoders, etc.
*
* @throws Exception if the operation fails
*/
suspend fun refreshEncoders() {
val result = listOptions(ListOptions.ENCODERS) as ListResult.Encoders
_videoEncoders.clear()
_videoEncoders.addAll(result.videoEncoders)
_audioEncoders.clear()
_audioEncoders.addAll(result.audioEncoders)
_videoEncoderTypes.clear()
_videoEncoderTypes.putAll(result.videoEncoderTypes)
_audioEncoderTypes.clear()
_audioEncoderTypes.putAll(result.audioEncoderTypes)
Log.i(TAG, "refreshEncoders(): video=${_videoEncoders.size}, audio=${_audioEncoders.size}")
}
/**
* Refresh camera sizes from the device.
* Results are cached and can be accessed via cameraSizes.
*
* @throws Exception if the operation fails
*/
suspend fun refreshCameraSizes() {
val result = listOptions(ListOptions.CAMERA_SIZES) as ListResult.CameraSizes
_cameraSizes.clear()
_cameraSizes.addAll(result.sizes.sortedWith(compareByDescending { size ->
size.substringBefore('x').toIntOrNull() ?: 0
}))
Log.i(TAG, "refreshCameraSizes(): sizes=${_cameraSizes.size}")
}
/**
* List various options from the scrcpy server.
*
* @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.)
* @return ListResult containing the requested information
*/
suspend fun listOptions(list: ListOptions): ListResult {
suspend fun listOptions(list: ListOptions): ListResult = withContext(Dispatchers.IO) {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(serverAsset)
} else {
@@ -261,7 +315,7 @@ class Scrcpy(
val output = adbService.shell("$serverCommand 2>&1")
// Parse output based on list option
return when (list) {
return@withContext when (list) {
ListOptions.NULL -> {
throw IllegalArgumentException("Nothing to do with ListOptions.NULL")
}
@@ -377,7 +431,7 @@ class Scrcpy(
serverJar: File,
options: ClientOptions,
scid: UInt,
): ScrcpySessionManager.SessionInfo {
): Session.SessionInfo {
adbService.push(serverJar.toPath(), serverRemotePath)
val serverParams = options.toServerParams(scid)
@@ -394,18 +448,22 @@ class Scrcpy(
// Execute server (equivalent to sc_adb_execute in C)
Log.i(TAG, "executeServer(): Starting scrcpy server")
logEvent("scrcpy-server args: $serverCommand")
return sessionManager.start(
serverJarPath = serverJar.toPath(),
val sessionInfo = session.start(
serverCommand = serverCommand,
scid = scid,
options = options,
)
Log.i(TAG, "executeServer(): session.start() returned, checking if session is still active")
if (!session.isStarted()) {
Log.e(TAG, "executeServer(): WARNING - session was cleared immediately after start()!")
}
return sessionInfo
}
private fun extractAssetToCache(assetPath: String): File {
val clean = assetPath.removePrefix("/")
val source = context.assets.open(clean)
val outputFile = File(context.cacheDir, File(clean).name)
val source = appContext.assets.open(clean)
val outputFile = File(appContext.cacheDir, File(clean).name)
source.use { input ->
outputFile.outputStream().use { output -> input.copyTo(output) }
}
@@ -414,11 +472,706 @@ class Scrcpy(
private fun extractUriToCache(uri: Uri): File {
val fileName = "custom-scrcpy-server.jar"
val outputFile = File(context.cacheDir, fileName)
context.contentResolver.openInputStream(uri).use { input ->
val outputFile = File(appContext.cacheDir, fileName)
appContext.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Unable to open selected server URI" }
outputFile.outputStream().use { output -> input.copyTo(output) }
}
return outputFile
}
/**
* Session manager for scrcpy protocol.
* Handles socket communication, video/audio streaming, and control input.
*/
class Session(private val adbService: NativeAdbService) {
private val mutex = Mutex()
@Volatile
private var activeSession: ActiveSession? = null
@Volatile
private var videoConsumer: ((VideoPacket) -> Unit)? = null
@Volatile
private var videoReaderThread: Thread? = null
@Volatile
private var audioConsumer: ((AudioPacket) -> Unit)? = null
@Volatile
private var audioReaderThread: Thread? = null
@Volatile
private var lastServerCommand: String? = null
private val serverLogBuffer = ArrayDeque<String>()
suspend fun start(
serverCommand: String,
scid: UInt,
options: ClientOptions,
): SessionInfo = mutex.withLock {
stopInternal()
serverLogBuffer.clear()
val socketName = socketNameFor(scid.toInt())
try {
lastServerCommand = serverCommand
val serverStream = adbService.openShellStream(serverCommand)
val serverLogThread = startServerLogThread(serverStream, socketName)
Thread.sleep(SERVER_BOOT_DELAY_MS)
val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true)
val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream))
var videoStream: AdbSocketStream? = null
var videoInput: DataInputStream? = null
var audioStream: AdbSocketStream? = null
var audioInput: DataInputStream? = null
var controlStream: AdbSocketStream? = null
when {
options.video -> {
videoStream = firstStream
videoInput = firstInput
}
options.audio -> {
audioStream = firstStream
audioInput = firstInput
}
options.control -> {
controlStream = firstStream
}
else -> {
throw IllegalArgumentException("At least one of video/audio/control must be enabled")
}
}
if (options.video && videoStream == null) {
val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
videoStream = vStream
videoInput = DataInputStream(BufferedInputStream(vStream.inputStream))
}
if (options.audio && audioStream == null) {
val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
audioStream = aStream
audioInput = DataInputStream(BufferedInputStream(aStream.inputStream))
}
if (options.control && controlStream == null) {
controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
}
val deviceName = readDeviceName(firstInput)
val audioCodecId =
if (options.audio) audioCodecIdFromName(options.audioCodec.string)
else 0
val codecId: Int
val width: Int
val height: Int
if (options.video) {
val vInput = checkNotNull(videoInput)
codecId = vInput.readInt()
width = vInput.readInt()
height = vInput.readInt()
} else {
codecId = 0
width = 0
height = 0
}
val sessionInfo = SessionInfo(
deviceName = deviceName,
codecId = codecId,
codecName = codecName(codecId),
width = width,
height = height,
audioCodecId = audioCodecId,
controlEnabled = controlStream != null,
)
val controlWriter = controlStream?.let { stream ->
ControlWriter(DataOutputStream(stream.outputStream))
}
val newSession = ActiveSession(
info = sessionInfo,
socketName = socketName,
serverStream = serverStream,
serverLogThread = serverLogThread,
videoStream = videoStream,
videoInput = videoInput,
audioStream = audioStream,
audioInput = audioInput,
controlStream = controlStream,
controlWriter = controlWriter,
)
activeSession = newSession
return sessionInfo
} catch (t: Throwable) {
val tail = snapshotServerLogs()
val detail = if (tail.isBlank()) "" else " | server_log_tail=\n$tail"
throw IllegalStateException("scrcpy start failed: ${t.message}$detail", t)
}
}
suspend fun attachVideoConsumer(consumer: (VideoPacket) -> Unit): Unit = mutex.withLock {
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
val vInput = session.videoInput ?: return
val vStream = session.videoStream ?: return
videoConsumer = consumer
if (videoReaderThread?.isAlive == true) {
return
}
videoReaderThread = thread(start = true, name = "scrcpy-video-reader") {
try {
while (activeSession === session && !vStream.closed) {
try {
val ptsAndFlags = vInput.readLong()
val packetSize = vInput.readInt()
if (packetSize <= 0) {
continue
}
val payload = ByteArray(packetSize)
vInput.readFully(payload)
val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L
val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L
val ptsUs = ptsAndFlags and PACKET_PTS_MASK
videoConsumer?.invoke(
VideoPacket(
data = payload,
ptsUs = ptsUs,
isConfig = config,
isKeyFrame = keyFrame,
),
)
} catch (_: EOFException) {
break
} catch (_: InterruptedException) {
if (activeSession !== session || vStream.closed) {
break
}
Thread.interrupted()
} catch (e: Exception) {
Log.w(TAG, "video reader failed", e)
break
}
}
} finally {
}
}
}
suspend fun clearVideoConsumer() = mutex.withLock {
videoConsumer = null
}
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
val aInput = session.audioInput ?: return
val aStream = session.audioStream ?: return
audioConsumer = consumer
if (audioReaderThread?.isAlive == true) return
audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") {
try {
val streamCodecId = try {
aInput.readInt()
} catch (e: Exception) {
Log.w(TAG, "audio codec header read failed", e)
return@thread
}
when (streamCodecId) {
AUDIO_DISABLED -> {
Log.w(TAG, "audio disabled by server")
return@thread
}
AUDIO_ERROR -> {
Log.e(TAG, "audio stream configuration error from server")
return@thread
}
else -> {
Log.i(
TAG,
"audio stream codec=0x${streamCodecId.toUInt().toString(16)}"
)
}
}
while (activeSession === session && !aStream.closed) {
try {
val ptsAndFlags = aInput.readLong()
val packetSize = aInput.readInt()
if (packetSize <= 0) continue
val payload = ByteArray(packetSize)
aInput.readFully(payload)
val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L
val ptsUs = ptsAndFlags and PACKET_PTS_MASK
audioConsumer?.invoke(
AudioPacket(
data = payload,
ptsUs = ptsUs,
isConfig = isConfig
)
)
} catch (_: EOFException) {
break
} catch (_: InterruptedException) {
if (activeSession !== session || aStream.closed) {
break
}
Thread.interrupted()
} catch (e: Exception) {
Log.w(TAG, "audio reader failed", e)
break
}
}
} finally {
}
}
}
suspend fun clearAudioConsumer() = mutex.withLock {
audioConsumer = null
}
suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) =
mutex.withLock {
try {
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectKeycode(): control channel not available", e)
}
}
suspend fun injectText(text: String) = mutex.withLock {
try {
requireControlWriter().injectText(text)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectText(): control channel not available", e)
}
}
suspend fun injectTouch(
action: Int,
pointerId: Long,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
actionButton: Int,
buttons: Int,
) = mutex.withLock {
try {
requireControlWriter().injectTouch(
action,
pointerId,
x,
y,
screenWidth,
screenHeight,
pressure,
actionButton,
buttons
)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectTouch(): control channel not available", e)
}
}
suspend fun injectScroll(
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int
) = mutex.withLock {
try {
requireControlWriter().injectScroll(
x,
y,
screenWidth,
screenHeight,
hScroll,
vScroll,
buttons
)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectScroll(): control channel not available", e)
}
}
suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
try {
requireControlWriter().pressBackOrScreenOn(action)
} catch (e: IllegalStateException) {
Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e)
}
}
suspend fun setDisplayPower(on: Boolean) = mutex.withLock {
try {
requireControlWriter().setDisplayPower(on)
} catch (e: IllegalStateException) {
Log.w(TAG, "setDisplayPower(): control channel not available", e)
}
}
suspend fun stop() = mutex.withLock {
stopInternal()
}
private fun stopInternal() {
val session = activeSession ?: return
activeSession = null
videoConsumer = null
audioConsumer = null
if (Thread.currentThread() !== videoReaderThread) {
runCatching { videoReaderThread?.interrupt() }
runCatching { videoReaderThread?.join(300) }
}
videoReaderThread = null
if (Thread.currentThread() !== audioReaderThread) {
runCatching { audioReaderThread?.interrupt() }
runCatching { audioReaderThread?.join(300) }
}
audioReaderThread = null
runCatching { session.controlStream?.close() }
runCatching { session.audioStream?.close() }
runCatching { session.videoStream?.close() }
runCatching { session.serverStream.close() }
if (Thread.currentThread() !== session.serverLogThread) {
runCatching { session.serverLogThread.interrupt() }
runCatching { session.serverLogThread.join(300) }
}
}
fun isStarted(): Boolean = activeSession != null
fun getLastServerCommand(): String? = lastServerCommand
private fun requireControlWriter(): ControlWriter {
val session = activeSession
?: throw IllegalStateException("scrcpy control channel not available")
return session.controlWriter
?: throw IllegalStateException("scrcpy control channel not available")
}
private fun startServerLogThread(
serverStream: AdbSocketStream,
socketName: String
): Thread {
return thread(start = true, name = "scrcpy-server-log") {
try {
BufferedReader(
InputStreamReader(
serverStream.inputStream,
Charsets.UTF_8
)
).use { reader ->
while (true) {
val line = reader.readLine() ?: break
synchronized(serverLogBuffer) {
if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) {
serverLogBuffer.removeFirst()
}
serverLogBuffer.addLast(line)
}
Log.i(TAG, "[server:$socketName] $line")
}
}
} catch (e: Exception) {
if (activeSession != null) {
Log.w(TAG, "server log thread failed", e)
}
}
}
}
private fun snapshotServerLogs(maxLines: Int = 120): String {
val snapshot = synchronized(serverLogBuffer) {
if (serverLogBuffer.isEmpty()) {
return ""
}
val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES)
serverLogBuffer.toList().takeLast(take)
}
return snapshot.joinToString("\n")
}
private suspend fun openAbstractSocketWithRetry(
socketName: String,
expectDummyByte: Boolean
): AdbSocketStream {
var lastEx: Exception? = null
repeat(CONNECT_RETRY_COUNT) { attempt ->
try {
val stream = adbService.openAbstractSocket(socketName)
if (expectDummyByte) {
val value = stream.inputStream.read()
if (value < 0) {
stream.close()
throw EOFException("scrcpy dummy byte missing")
}
}
return stream
} catch (e: Exception) {
lastEx = e
if (attempt < CONNECT_RETRY_COUNT - 1) Thread.sleep(CONNECT_RETRY_DELAY_MS)
}
}
throw IllegalStateException("Unable to open scrcpy socket '$socketName'", lastEx)
}
private fun readDeviceName(input: DataInputStream): String {
val buffer = ByteArray(DEVICE_NAME_FIELD_LENGTH)
input.readFully(buffer)
val firstZero = buffer.indexOf(0)
val length = if (firstZero >= 0) firstZero else buffer.size
return buffer.copyOf(length).toString(Charsets.UTF_8)
}
private fun codecName(codecId: Int) =
when (codecId) {
VIDEO_CODEC_H264 -> "h264"
VIDEO_CODEC_H265 -> "h265"
VIDEO_CODEC_AV1 -> "av1"
else -> "unknown"
}
private fun audioCodecIdFromName(name: String) =
when (name.lowercase()) {
"opus" -> AUDIO_CODEC_OPUS
"aac" -> AUDIO_CODEC_AAC
"raw" -> AUDIO_CODEC_RAW
"flac" -> AUDIO_CODEC_FLAC
else -> 0
}
data class SessionInfo(
val deviceName: String,
val codecId: Int,
val codecName: String,
val width: Int,
val height: Int,
val audioCodecId: Int = 0,
val controlEnabled: Boolean,
val host: String = "",
val port: Int = Defaults.ADB_PORT,
)
data class VideoPacket(
val data: ByteArray,
val ptsUs: Long,
val isConfig: Boolean,
val isKeyFrame: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VideoPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (isKeyFrame != other.isKeyFrame) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = ptsUs.hashCode()
result = 31 * result + isConfig.hashCode()
result = 31 * result + isKeyFrame.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
data class AudioPacket(
val data: ByteArray,
val ptsUs: Long,
val isConfig: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AudioPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = ptsUs.hashCode()
result = 31 * result + isConfig.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
private data class ActiveSession(
val info: SessionInfo,
val socketName: String,
val serverStream: AdbSocketStream,
val serverLogThread: Thread,
val videoStream: AdbSocketStream?,
val videoInput: DataInputStream?,
val audioStream: AdbSocketStream?,
val audioInput: DataInputStream?,
val controlStream: AdbSocketStream?,
val controlWriter: ControlWriter?,
)
private class ControlWriter(private val output: DataOutputStream) {
@Synchronized
fun injectKeycode(action: Int, keycode: Int, repeat: Int, metaState: Int) {
output.writeByte(TYPE_INJECT_KEYCODE)
output.writeByte(action)
output.writeInt(keycode)
output.writeInt(repeat)
output.writeInt(metaState)
output.flush()
}
@Synchronized
fun injectText(text: String) {
val bytes = text.toByteArray(Charsets.UTF_8)
output.writeByte(TYPE_INJECT_TEXT)
output.writeInt(bytes.size)
output.write(bytes)
output.flush()
}
@Synchronized
fun injectTouch(
action: Int,
pointerId: Long,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
actionButton: Int,
buttons: Int,
) {
output.writeByte(TYPE_INJECT_TOUCH_EVENT)
output.writeByte(action)
output.writeLong(pointerId)
writePosition(x, y, screenWidth, screenHeight)
output.writeShort(encodeUnsignedFixedPoint16(pressure))
output.writeInt(actionButton)
output.writeInt(buttons)
output.flush()
}
@Synchronized
fun injectScroll(
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int
) {
output.writeByte(TYPE_INJECT_SCROLL_EVENT)
writePosition(x, y, screenWidth, screenHeight)
output.writeShort(encodeSignedFixedPoint16(hScroll / 16f))
output.writeShort(encodeSignedFixedPoint16(vScroll / 16f))
output.writeInt(buttons)
output.flush()
}
@Synchronized
fun pressBackOrScreenOn(action: Int) {
output.writeByte(TYPE_BACK_OR_SCREEN_ON)
output.writeByte(action)
output.flush()
}
@Synchronized
fun setDisplayPower(on: Boolean) {
output.writeByte(TYPE_SET_DISPLAY_POWER)
output.writeBoolean(on)
output.flush()
}
private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
output.writeInt(x)
output.writeInt(y)
output.writeShort(screenWidth)
output.writeShort(screenHeight)
}
private fun encodeUnsignedFixedPoint16(value: Float): Int {
val clamped = value.coerceIn(0f, 1f)
return if (clamped >= 1f) {
0xffff
} else {
(clamped * 65536f).roundToInt().coerceIn(0, 0xfffe)
}
}
private fun encodeSignedFixedPoint16(value: Float): Int {
val clamped = value.coerceIn(-1f, 1f)
if (clamped >= 1f) {
return 0x7fff
}
if (clamped <= -1f) {
return -0x8000
}
return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe)
}
}
companion object {
private const val SERVER_BOOT_DELAY_MS = 200L
private const val SERVER_LOG_BUFFER_MAX_LINES = 400
private const val CONNECT_RETRY_COUNT = 100
private const val CONNECT_RETRY_DELAY_MS = 100L
private const val DEVICE_NAME_FIELD_LENGTH = 64
private const val PACKET_FLAG_CONFIG = 1L shl 63
private const val PACKET_FLAG_KEY_FRAME = 1L shl 62
private const val PACKET_PTS_MASK = (1L shl 62) - 1
private const val VIDEO_CODEC_H264 = 0x68323634
private const val VIDEO_CODEC_H265 = 0x68323635
private const val VIDEO_CODEC_AV1 = 0x00617631
private const val AUDIO_CODEC_OPUS = 0x6f707573
private const val AUDIO_CODEC_AAC = 0x00616163
private const val AUDIO_CODEC_FLAC = 0x666c6163
private const val AUDIO_CODEC_RAW = 0x00726177
private const val AUDIO_DISABLED = 0
private const val AUDIO_ERROR = 1
private const val TYPE_INJECT_KEYCODE = 0
private const val TYPE_INJECT_TEXT = 1
private const val TYPE_INJECT_TOUCH_EVENT = 2
private const val TYPE_INJECT_SCROLL_EVENT = 3
private const val TYPE_BACK_OR_SCREEN_ON = 4
private const val TYPE_SET_DISPLAY_POWER = 10
private fun socketNameFor(scid: Int): String {
return "scrcpy_%08x".format(scid)
}
}
}
}

View File

@@ -45,8 +45,8 @@ data class ServerParams(
var maxSize: UShort,
var videoBitRate: UInt,
var audioBitRate: UInt,
var videoBitRate: Int,
var audioBitRate: Int,
var maxFps: String, // float to be parsed by the server
var angle: String, // float to be parsed by the server
@@ -58,7 +58,7 @@ data class ServerParams(
var control: Boolean,
var displayId: UInt,
var displayId: Int,
var newDisplay: String,
var displayImePolicy: DisplayImePolicy,
@@ -96,7 +96,7 @@ data class ServerParams(
const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n"
}
private fun validate(str: String): Unit {
private fun validate(str: String) {
// forbid special shell characters
if (str.any { it in ILLEGAL_CHARACTER_SET }) {
throw IllegalArgumentException("Invalid server param: [$str]")
@@ -107,23 +107,28 @@ data class ServerParams(
return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR)
}
fun toList(): MutableList<String> {
fun toList(simplify: Boolean = false): MutableList<String> {
val cmd = mutableListOf<String>()
cmd.add("scid=${scid.toString(16)}")
cmd.add("log_level=${logLevel.string}")
if (!simplify) {
cmd.add("scid=${scid.toString(16)}")
cmd.add("log_level=${logLevel.string}")
}
if (!video) {
cmd.add("video=false")
}
if (videoBitRate > 0u) {
if (videoBitRate > 0) {
cmd.add("video_bit_rate=$videoBitRate")
}
if (!audio) {
cmd.add("audio=false")
}
if (audioBitRate > 0u) {
cmd.add("audio_bit_rate=$audioBitRate")
if (audioBitRate > 0) {
if (audioCodec.isLossyAudio()!!) {
// 比官方实现多个判断编解码器类型
cmd.add("audio_bit_rate=$audioBitRate")
}
}
if (videoCodec != Codec.H264) {
cmd.add("video_codec=${videoCodec.string}")
@@ -177,7 +182,7 @@ data class ServerParams(
// By default, control is true
cmd.add("control=false")
}
if (displayId > 0u) {
if (displayId >= 0) {
cmd.add("display_id=$displayId")
}
if (cameraId.isNotBlank()) {

View File

@@ -60,19 +60,33 @@ class Shared {
ERROR("error");
}
enum class Codec(val string: String) {
H264("h264"), // default, ignore when passing
H265("h265"),
AV1("av1"),
OPUS("opus"), // default, ignore when passing
AAC("aac"),
FLAC("flac"),
RAW("raw"); // wav raw
enum class Codec(val string: String, val displayName: String) {
H264("h264", "H.264"), // default, ignore when passing
H265("h265", "H.265"),
AV1("av1", "AV1"),
OPUS("opus", "Opus"), // default, ignore when passing
AAC("aac", "AAC"),
FLAC("flac", "FLAC"),
RAW("raw", "RAW"); // wav raw
fun isLossyAudio(): Boolean? = when (this) {
OPUS, AAC -> true
FLAC, RAW -> false
else -> null
}
enum class Type { VIDEO, AUDIO }
companion object {
fun fromString(value: String): Codec {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264
}
val VIDEO = listOf(H264, H265, AV1)
val AUDIO = listOf(OPUS, AAC, FLAC, RAW)
fun fromString(value: String, type: Type = Type.VIDEO) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: when (type) {
Type.VIDEO -> H264
Type.AUDIO -> OPUS
}
}
}
@@ -81,9 +95,9 @@ class Shared {
CAMERA("camera");
companion object {
fun fromString(value: String): VideoSource {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: DISPLAY
}
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: DISPLAY
}
}
@@ -102,9 +116,9 @@ class Shared {
VOICE_PERFORMANCE("voice-performance");
companion object {
fun fromString(value: String): AudioSource {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: AUTO
}
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: AUTO
}
}
@@ -115,9 +129,9 @@ class Shared {
EXTERNAL("external");
companion object {
fun fromString(value: String): CameraFacing {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: ANY
}
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: ANY
}
}

View File

@@ -1,8 +1,8 @@
package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal data class ConnectedDeviceInfo(
val model: String,
@@ -18,16 +18,15 @@ internal data class ConnectedDeviceInfo(
* Fetch basic device properties from an already-connected device.
*
* Notes:
* - This function issues multiple `adb shell getprop` commands via [nativeCore.adbShell].
* Each call may block on native I/O, so callers should execute this on the dedicated
* ADB worker dispatcher rather than the UI thread.
* - This function issues multiple `shell getprop` commands via [adbService.shell].
* Each call may block on native I/O, so it should be called from a coroutine context.
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
*/
internal fun fetchConnectedDeviceInfo(
internal suspend fun fetchConnectedDeviceInfo(
adbService: NativeAdbService,
host: String,
port: Int
): ConnectedDeviceInfo = runBlocking {
): ConnectedDeviceInfo = withContext(Dispatchers.IO) {
suspend fun prop(name: String): String = runCatching {
adbService.shell("getprop $name").trim()
}.getOrDefault("")

View File

@@ -18,7 +18,7 @@ class PreferenceMigration(private val appContext: Context) {
private val appSharedPrefs: SharedPreferences by lazy {
appContext.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
}
private val sharedPrefs: SharedPreferences by lazy {
appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE)
}

View File

@@ -22,207 +22,215 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
companion object {
val CROP = Pair(
stringPreferencesKey("crop"),
""
"",
)
val RECORD_FILENAME = Pair(
stringPreferencesKey("record_filename"),
""
"",
)
val VIDEO_CODEC_OPTIONS = Pair(
stringPreferencesKey("video_codec_options"),
""
"",
)
val AUDIO_CODEC_OPTIONS = Pair(
stringPreferencesKey("audio_codec_options"),
""
"",
)
val VIDEO_ENCODER = Pair(
stringPreferencesKey("video_encoder"),
""
"",
)
val AUDIO_ENCODER = Pair(
stringPreferencesKey("audio_encoder"),
""
"",
)
val CAMERA_ID = Pair(
stringPreferencesKey("camera_id"),
""
"",
)
val CAMERA_SIZE = Pair(
stringPreferencesKey("camera_size"),
""
"",
)
val CAMERA_SIZE_CUSTOM = Pair(
stringPreferencesKey("camera_size_custom"),
"",
)
val CAMERA_SIZE_USE_CUSTOM = Pair(
booleanPreferencesKey("camera_size_use_custom"),
false,
)
val CAMERA_AR = Pair(
stringPreferencesKey("camera_ar"),
""
"",
)
val CAMERA_FPS = Pair(
intPreferencesKey("camera_fps"),
0
0,
)
val LOG_LEVEL = Pair(
stringPreferencesKey("log_level"),
"info"
"info",
)
val VIDEO_CODEC = Pair(
stringPreferencesKey("video_codec"),
"h264"
"h264",
)
val AUDIO_CODEC = Pair(
stringPreferencesKey("audio_codec"),
"opus"
"opus",
)
val VIDEO_SOURCE = Pair(
stringPreferencesKey("video_source"),
"display"
"display",
)
val AUDIO_SOURCE = Pair(
stringPreferencesKey("audio_source"),
"output"
"output",
)
val RECORD_FORMAT = Pair(
stringPreferencesKey("record_format"),
"auto"
"auto",
)
val CAMERA_FACING = Pair(
stringPreferencesKey("camera_facing"),
"any"
"any",
)
val MAX_SIZE = Pair(
intPreferencesKey("max_size"),
0
0,
)
val VIDEO_BIT_RATE = Pair(
intPreferencesKey("video_bit_rate"),
8000000
8000000,
)
val AUDIO_BIT_RATE = Pair(
intPreferencesKey("audio_bit_rate"),
128000
128000,
)
val MAX_FPS = Pair(
stringPreferencesKey("max_fps"),
""
"",
)
val ANGLE = Pair(
stringPreferencesKey("angle"),
""
"",
)
val CAPTURE_ORIENTATION = Pair(
intPreferencesKey("capture_orientation"),
0
0,
)
val CAPTURE_ORIENTATION_LOCK = Pair(
stringPreferencesKey("capture_orientation_lock"),
"unlocked"
"unlocked",
)
val DISPLAY_ORIENTATION = Pair(
intPreferencesKey("display_orientation"),
0
0,
)
val RECORD_ORIENTATION = Pair(
intPreferencesKey("record_orientation"),
0
0,
)
val DISPLAY_IME_POLICY = Pair(
stringPreferencesKey("display_ime_policy"),
"undefined"
"undefined",
)
val DISPLAY_ID = Pair(
intPreferencesKey("display_id"),
0
-1, // undefined
)
val SCREEN_OFF_TIMEOUT = Pair(
longPreferencesKey("screen_off_timeout"),
-1
-1,
)
val SHOW_TOUCHES = Pair(
booleanPreferencesKey("show_touches"),
false
false,
)
val FULLSCREEN = Pair(
booleanPreferencesKey("fullscreen"),
false
false,
)
val CONTROL = Pair(
booleanPreferencesKey("control"),
true
true,
)
val VIDEO_PLAYBACK = Pair(
booleanPreferencesKey("video_playback"),
true
true,
)
val AUDIO_PLAYBACK = Pair(
booleanPreferencesKey("audio_playback"),
true
true,
)
val TURN_SCREEN_OFF = Pair(
booleanPreferencesKey("turn_screen_off"),
false
false,
)
val STAY_AWAKE = Pair(
booleanPreferencesKey("stay_awake"),
false
false,
)
val DISABLE_SCREENSAVER = Pair(
booleanPreferencesKey("disable_screensaver"),
false
false,
)
val POWER_OFF_ON_CLOSE = Pair(
booleanPreferencesKey("power_off_on_close"),
false
false,
)
val CLEANUP = Pair(
booleanPreferencesKey("cleanup"),
true
true,
)
val POWER_ON = Pair(
booleanPreferencesKey("power_on"),
true
true,
)
val VIDEO = Pair(
booleanPreferencesKey("video"),
true
true,
)
val AUDIO = Pair(
booleanPreferencesKey("audio"),
true
true,
)
val REQUIRE_AUDIO = Pair(
booleanPreferencesKey("require_audio"),
false
false,
)
val KILL_ADB_ON_CLOSE = Pair(
booleanPreferencesKey("kill_adb_on_close"),
false
false,
)
val CAMERA_HIGH_SPEED = Pair(
booleanPreferencesKey("camera_high_speed"),
false
false,
)
val LIST = Pair(
stringPreferencesKey("list"),
"null"
"null",
)
val AUDIO_DUP = Pair(
booleanPreferencesKey("audio_dup"),
false
false,
)
val NEW_DISPLAY = Pair(
stringPreferencesKey("new_display"),
""
"",
)
val START_APP = Pair(
stringPreferencesKey("start_app"),
""
"",
)
val VD_DESTROY_CONTENT = Pair(
booleanPreferencesKey("vd_destroy_content"),
true
true,
)
val VD_SYSTEM_DECORATIONS = Pair(
booleanPreferencesKey("vd_system_decorations"),
true
true,
)
}
@@ -234,6 +242,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val audioEncoder by setting(AUDIO_ENCODER)
val cameraId by setting(CAMERA_ID)
val cameraSize by setting(CAMERA_SIZE)
val cameraSizeCustom by setting(CAMERA_SIZE_CUSTOM)
val cameraSizeUseCustom by setting(CAMERA_SIZE_USE_CUSTOM)
val cameraAr by setting(CAMERA_AR)
val cameraFps by setting(CAMERA_FPS)
val logLevel by setting(LOG_LEVEL)
@@ -294,19 +304,19 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
videoEncoder = videoEncoder.get(),
audioEncoder = audioEncoder.get(),
cameraId = cameraId.get(),
cameraSize = cameraSize.get(),
cameraSize = if (!cameraSizeUseCustom.get()) cameraSize.get() else cameraSizeCustom.get(),
cameraAr = cameraAr.get(),
cameraFps = cameraFps.get().toUShort(),
logLevel = LogLevel.valueOf(logLevel.get().uppercase()),
videoCodec = Codec.valueOf(videoCodec.get().uppercase()),
audioCodec = Codec.valueOf(audioCodec.get().uppercase()),
videoSource = VideoSource.valueOf(videoSource.get().uppercase()),
audioSource = AudioSource.valueOf(audioSource.get().uppercase()),
videoCodec = Codec.fromString(videoCodec.get()),
audioCodec = Codec.fromString(audioCodec.get()),
videoSource = VideoSource.fromString(videoSource.get()),
audioSource = AudioSource.fromString(audioSource.get()),
recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()),
cameraFacing = CameraFacing.valueOf(cameraFacing.get().uppercase()),
cameraFacing = CameraFacing.fromString(cameraFacing.get()),
maxSize = maxSize.get().toUShort(),
videoBitRate = videoBitRate.get().toUInt(),
audioBitRate = audioBitRate.get().toUInt(),
videoBitRate = videoBitRate.get(),
audioBitRate = audioBitRate.get(),
maxFps = maxFps.get(),
angle = angle.get(),
captureOrientation = Orientation.fromInt(captureOrientation.get()),
@@ -316,7 +326,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
displayOrientation = Orientation.fromInt(displayOrientation.get()),
recordOrientation = Orientation.fromInt(recordOrientation.get()),
displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()),
displayId = displayId.get().toUInt(),
displayId = displayId.get(),
screenOffTimeout = Tick(screenOffTimeout.get()),
showTouches = showTouches.get(),
fullscreen = fullscreen.get(),

View File

@@ -54,6 +54,8 @@ abstract class Settings(
operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty<T> = this
suspend fun isDefaultValue() = getValue(pair) == pair.defaultValue
suspend fun get(): T = getValue(pair)
suspend fun set(value: T) = setValue(pair, value)

View File

@@ -2,10 +2,12 @@ package io.github.miuzarte.scrcpyforandroid.widgets
import android.annotation.SuppressLint
import android.graphics.SurfaceTexture
import android.util.Log
import android.view.MotionEvent
import android.view.Surface
import android.view.TextureView
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@@ -36,7 +38,6 @@ import androidx.compose.material.icons.rounded.Fullscreen
import androidx.compose.material.icons.rounded.LinkOff
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -48,9 +49,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
@@ -67,13 +65,16 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -97,23 +98,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import kotlin.math.roundToInt
// TODO: migrate to scrcpy.Shared
@Deprecated("scrcpy.Shared.Codec")
private val VIDEO_CODEC_OPTIONS = listOf(
"h264" to "H.264",
"h265" to "H.265",
"av1" to "AV1",
)
@Deprecated("scrcpy.Shared.Codec")
private val AUDIO_CODEC_OPTIONS = listOf(
"opus" to "Opus",
"aac" to "AAC",
"flac" to "FLAC",
"raw" to "RAW",
)
private object UiMotionActions {
const val DOWN = 0
const val UP = 1
@@ -130,7 +114,7 @@ internal fun StatusCard(
statusLine: String,
adbConnected: Boolean,
streaming: Boolean,
sessionInfo: ScrcpySessionInfo?,
sessionInfo: Scrcpy.Session.SessionInfo?,
busyLabel: String?,
connectedDeviceLabel: String,
) {
@@ -183,7 +167,7 @@ internal fun StatusCard(
),
secondSmall = StatusSmallCardSpec(
"编解码器",
sessionInfo.codec,
sessionInfo.codecName,
),
)
}
@@ -270,7 +254,7 @@ internal fun PairingCard(
@Composable
internal fun PreviewCard(
sessionInfo: ScrcpySessionInfo?,
sessionInfo: Scrcpy.Session.SessionInfo?,
nativeCore: NativeCoreFacade,
previewHeightDp: Int,
controlsVisible: Boolean,
@@ -376,19 +360,51 @@ internal fun VirtualButtonCard(
internal fun ConfigPanel(
busy: Boolean,
audioForwardingSupported: Boolean,
cameraMirroringSupported: Boolean,
onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null,
onStart: () -> Unit,
onStop: () -> Unit,
sessionStarted: Boolean,
sessionInfo: Scrcpy.Session.SessionInfo?,
onDisconnect: () -> Unit = {},
) {
val scrcpyOptions = Storage.scrcpyOptions
val quickDevices = Storage.quickDevices
val context = LocalContext.current
val sessionStarted = sessionInfo != null
// Check if device exists in shortcuts
var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
val savedShortcuts = remember(quickDevicesList) {
DeviceShortcuts.unmarshalFrom(quickDevicesList)
}
val isQuickConnected = remember(sessionInfo, savedShortcuts) {
sessionInfo?.let { info ->
savedShortcuts.get(info.host, info.port) == null
} ?: false
}
var audio by scrcpyOptions.audio.asMutableState()
var audioCodec by scrcpyOptions.audioCodec.asMutableState()
val audioCodecItems = remember { Codec.AUDIO.map { it.displayName } }
val audioCodecIndex = Codec.AUDIO.indexOfFirst {
it.string == audioCodec
}.coerceAtLeast(0)
var audioBitRate by scrcpyOptions.audioBitRate.asMutableState()
var videoCodec by scrcpyOptions.videoCodec.asMutableState()
val videoCodecItems = remember { Codec.VIDEO.map { it.displayName } }
val videoCodecIndex = Codec.VIDEO.indexOfFirst {
it.string == videoCodec
}.coerceAtLeast(0)
var videoBitRate by scrcpyOptions.videoBitRate.asMutableState()
val videoBitRateMbps = videoBitRate / 1_000_000f
SectionSmallTitle("Scrcpy")
Card {
var audio by scrcpyOptions.audio.asMutableState()
SuperSwitch(
title = "音频转发",
summary = "转发设备音频到本机 (Android 11+)",
@@ -397,64 +413,46 @@ internal fun ConfigPanel(
enabled = !sessionStarted && audioForwardingSupported,
)
var audioCodec by scrcpyOptions.audioCodec.asMutableState()
val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } }
val audioCodecIndex = AUDIO_CODEC_OPTIONS.indexOfFirst {
it.first == audioCodec
}.coerceAtLeast(0)
var audioBitRate by scrcpyOptions.audioBitRate.asMutableState()
val audioBitRateKbps = audioBitRate * 1_000f
val audioBitRatePresetIndex = presetIndexFromInput(
audioBitRateKbps.toString(),
ScrcpyPresets.AudioBitRate
)
SuperDropdown(
title = "音频编码",
summary = "--audio-codec",
items = audioCodecItems,
selectedIndex = audioCodecIndex,
onSelectedIndexChange = { audioCodec = AUDIO_CODEC_OPTIONS[it].first },
onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string },
enabled = !sessionStarted && audio,
)
if (audio && (audioCodec == "opus" || audioCodec == "aac")) {
SuperSlide(
AnimatedVisibility(audio && (audioCodec == "opus" || audioCodec == "aac")) {
SuperSlider(
title = "音频码率",
summary = "--audio-bit-rate",
value = audioBitRatePresetIndex.toFloat(),
value = ScrcpyPresets.AudioBitRate.indexOfOrNearest(audioBitRate / 1000).toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1024
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
},
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "Kbps",
displayText = audioBitRate.toString(),
inputInitialValue = audioBitRate.toString(),
displayText = (audioBitRate / 1000).toString(),
inputInitialValue = (audioBitRate / 1000).toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 1f..Float.MAX_VALUE,
inputValueRange = 1f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it }
raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it * 1000 }
},
)
}
var videoCodec by scrcpyOptions.videoCodec.asMutableState()
val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } }
val videoCodecIndex = VIDEO_CODEC_OPTIONS.indexOfFirst {
it.first == videoCodec
}.coerceAtLeast(0)
var videoBitRate by scrcpyOptions.videoBitRate.asMutableState()
val videoBitRateMbps = videoBitRate / 1_000_000f
SuperDropdown(
title = "视频编码",
summary = "--video-codec",
items = videoCodecItems,
selectedIndex = videoCodecIndex,
onSelectedIndexChange = { videoCodec = VIDEO_CODEC_OPTIONS[it].first },
onSelectedIndexChange = { videoCodec = Codec.VIDEO[it].string },
enabled = !sessionStarted,
)
SuperSlide(
SuperSlider(
title = "视频码率",
summary = "--video-bit-rate",
value = videoBitRateMbps,
@@ -481,7 +479,7 @@ internal fun ConfigPanel(
}
}
},
inputValueRange = 0.1f..Float.MAX_VALUE,
inputValueRange = 0.1f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toFloatOrNull()?.let { parsed ->
if (parsed >= 0.1f) {
@@ -498,23 +496,61 @@ internal fun ConfigPanel(
enabled = !sessionStarted,
)
TextButton(
text = if (sessionStarted) "停止" else "启动",
onClick = {
onStartStopHaptic?.invoke()
if (sessionStarted) onStop() else onStart()
},
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
enabled = !busy,
colors = if (sessionStarted) {
ButtonDefaults.textButtonColors()
} else {
ButtonDefaults.textButtonColorsPrimary()
},
)
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
AnimatedVisibility(!isQuickConnected) {
AnimatedVisibility(!sessionStarted) {
TextButton(
text = "启动",
onClick = {
onStartStopHaptic?.invoke()
onStart()
},
modifier = Modifier.fillMaxWidth(),
enabled = !busy,
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
AnimatedVisibility(sessionStarted) {
TextButton(
text = "停止",
onClick = {
onStartStopHaptic?.invoke()
onStop()
},
modifier = Modifier.fillMaxWidth(),
enabled = !busy,
)
}
}
// display them at the same time in quick connection
AnimatedVisibility(isQuickConnected) {
TextButton(
text = "断开",
onClick = {
onStartStopHaptic?.invoke()
onDisconnect()
},
modifier = Modifier.weight(1f/4f),
enabled = !busy,
)
TextButton(
text = "启动",
onClick = {
onStartStopHaptic?.invoke()
onStart()
},
modifier = Modifier.weight(3f/4f),
enabled = !busy,
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
}
}
@@ -576,10 +612,12 @@ private fun PairingDialog(
title = "使用配对码配对设备",
summary = "使用六位数的配对码配对新设备",
onDismissRequest = {
clearInputs()
onDismissRequest()
},
onDismissFinished = onDismissFinished,
onDismissFinished = {
clearInputs()
onDismissFinished()
},
content = {
TextField(
value = host,
@@ -634,7 +672,6 @@ private fun PairingDialog(
TextButton(
text = "取消",
onClick = {
clearInputs()
onDismissRequest()
},
modifier = Modifier.weight(1f),
@@ -643,7 +680,7 @@ private fun PairingDialog(
text = "配对",
onClick = {
onConfirm(host.trim(), port.trim(), code.trim())
clearInputs()
onDismissRequest()
},
enabled = enabled &&
host.trim().isNotBlank() &&
@@ -657,15 +694,6 @@ private fun PairingDialog(
)
}
private fun presetIndexFromInput(raw: String, presets: List<Int>): Int {
if (raw.isBlank()) return 0
val value = raw.toIntOrNull() ?: return 0
val exact = presets.indexOf(value)
if (exact >= 0) return exact
val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) }
return nearest?.index ?: 0
}
@SuppressLint("DefaultLocale")
private fun formatBitRate(value: Float): String = String.format("%.1f", value)
@@ -690,7 +718,7 @@ internal fun LogsPanel(lines: List<String>) {
*/
class TouchEventHandler(
private val coroutineScope: CoroutineScope,
private val session: ScrcpySessionInfo,
private val session: Scrcpy.Session.SessionInfo,
private val touchAreaSize: IntSize,
private val activePointerIds: LinkedHashSet<Int>,
private val activePointerPositions: LinkedHashMap<Int, Offset>,
@@ -814,7 +842,11 @@ class TouchEventHandler(
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
coroutineScope.launch {
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
runCatching {
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
}.onFailure { e ->
Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e)
}
}
activePointerIds -= pointerId
activePointerPositions.remove(pointerId)
@@ -877,7 +909,15 @@ class TouchEventHandler(
activePointerDevicePositions[pointerId] = x to y
justPressedPointerIds += pointerId
coroutineScope.launch {
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
runCatching {
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerDown failed for pointerId=$pointerId",
e
)
}
}
}
}
@@ -899,7 +939,15 @@ class TouchEventHandler(
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerDevicePositions[pointerId] = x to y
coroutineScope.launch {
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
runCatching {
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerMove failed for pointerId=$pointerId",
e
)
}
}
}
}
@@ -937,7 +985,7 @@ class TouchEventHandler(
*/
@Composable
fun FullscreenControlScreen(
session: ScrcpySessionInfo,
session: Scrcpy.Session.SessionInfo,
nativeCore: NativeCoreFacade,
onDismiss: () -> Unit,
showDebugInfo: Boolean,
@@ -1071,30 +1119,16 @@ fun FullscreenControlScreen(
private fun ScrcpyVideoSurface(
modifier: Modifier,
nativeCore: NativeCoreFacade,
session: ScrcpySessionInfo?,
session: Scrcpy.Session.SessionInfo?,
) {
val surfaceTag = "video-main"
var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(session, currentSurface) {
if (session != null && currentSurface != null) {
nativeCore.registerVideoSurface(surfaceTag, currentSurface!!)
}
// Unregistration is handled directly in onSurfaceTextureDestroyed and DisposableEffect
}
DisposableEffect(Unit) {
onDispose {
val released = currentSurface
if (released != null) {
scope.launch {
nativeCore.unregisterVideoSurface(surfaceTag, released)
}
released.release()
currentSurface = null
}
// If currentSurface is null, onSurfaceTextureDestroyed already handled cleanup
val surface = currentSurface
if (session != null && surface != null && surface.isValid) {
nativeCore.registerVideoSurface(surfaceTag, surface)
}
}
@@ -1108,9 +1142,15 @@ private fun ScrcpyVideoSurface(
width: Int,
height: Int
) {
currentSurface?.release() // Release stale surface if any
@SuppressLint("Recycle")
currentSurface = Surface(surfaceTexture)
val newSurface = Surface(surfaceTexture)
currentSurface = newSurface
// Register immediately when surface becomes available
if (session != null) {
scope.launch {
nativeCore.registerVideoSurface(surfaceTag, newSurface)
}
}
}
override fun onSurfaceTextureSizeChanged(
@@ -1120,15 +1160,9 @@ private fun ScrcpyVideoSurface(
) = Unit
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
val released = currentSurface
currentSurface = null
if (released != null) {
scope.launch {
nativeCore.unregisterVideoSurface(surfaceTag, released)
}
released.release()
}
return true
// Return false to keep the SurfaceTexture alive
// This prevents the surface from being destroyed when the view is detached
return false
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit
@@ -1222,15 +1256,13 @@ internal fun DeviceTile(
internal fun QuickConnectCard(
input: String,
onValueChange: (String) -> Unit,
onFocusChange: (() -> Unit)? = null,
onConnect: () -> Unit,
onAddDevice: () -> Unit,
enabled: Boolean = true,
) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var tempText by remember(input) { mutableStateOf(input) }
var tempFocusState by remember(input) { mutableStateOf(false) }
Card(
colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer),
@@ -1259,9 +1291,9 @@ internal fun QuickConnectCard(
color = MiuixTheme.colorScheme.onPrimaryContainer,
)
}
TextField(
value = tempText,
onValueChange = { tempText = it },
SuperTextField(
value = input,
onValueChange = onValueChange,
label = "IP:PORT",
enabled = enabled,
useLabelAsPlaceholder = true,
@@ -1269,17 +1301,10 @@ internal fun QuickConnectCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.SectionTitleLeadingGap)
.onFocusChanged { focusState ->
// 失去焦点时回调
if (!focusState.isFocused && tempFocusState) {
onValueChange(tempText)
}
tempFocusState = focusState.isFocused
}
.focusRequester(focusRequester),
.padding(bottom = UiSpacing.SectionTitleLeadingGap),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onFocusLost = onFocusChange,
)
Row(
modifier = Modifier