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

@@ -98,6 +98,7 @@ dependencies {
implementation("org.conscrypt:conscrypt-android:2.5.2") implementation("org.conscrypt:conscrypt-android:2.5.2")
implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("sh.calvin.reorderable:reorderable:3.0.0")
implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences:1.2.1")
implementation(libs.androidx.compose.runtime)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View File

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

View File

@@ -6,9 +6,7 @@ import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.ArrayDeque import java.util.ArrayDeque
@@ -16,15 +14,17 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CopyOnWriteArraySet
/** /**
* Facade that centralizes video rendering and ADB operations. * Facade that centralizes video rendering.
* *
* Provides helpers for: * Provides helpers for:
* - ADB operations (all suspend functions)
* - Surface/Decoder management for video rendering * - Surface/Decoder management for video rendering
* - Control input injection (all suspend functions) * - Video size and FPS monitoring
*/ */
class NativeCoreFacade(private val appContext: Context) { class NativeCoreFacade private constructor() {
val sessionManager = ScrcpySessionManager(NativeAdbService(appContext)) @Volatile
var session: Scrcpy.Session? = null
private set
private val sessionLifecycleMutex = Mutex() private val sessionLifecycleMutex = Mutex()
private val surfaceMap = ConcurrentHashMap<String, Surface>() private val surfaceMap = ConcurrentHashMap<String, Surface>()
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>() private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
@@ -40,10 +40,7 @@ class NativeCoreFacade(private val appContext: Context) {
private var packetCount: Long = 0 private var packetCount: Long = 0
@Volatile @Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
@Volatile
private var currentSessionInfo: ScrcpySessionInfo? = null
suspend fun close() { suspend fun close() {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
@@ -63,6 +60,10 @@ class NativeCoreFacade(private val appContext: Context) {
*/ */
suspend fun registerVideoSurface(tag: String, surface: Surface) { suspend fun registerVideoSurface(tag: String, surface: Surface) {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
if (!surface.isValid) {
Log.w(TAG, "registerVideoSurface(): skip invalid surface for tag=$tag")
return
}
val newId = System.identityHashCode(surface) val newId = System.identityHashCode(surface)
val oldId = surfaceIdentityMap[tag] val oldId = surfaceIdentityMap[tag]
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) { if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
@@ -103,12 +104,12 @@ class NativeCoreFacade(private val appContext: Context) {
) )
return return
} }
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId") Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId, releasing decoder")
surfaceMap.remove(tag) surfaceMap.remove(tag)
surfaceIdentityMap.remove(tag) surfaceIdentityMap.remove(tag)
if (currentSessionInfo == null) { // Always release decoder when surface is unregistered
decoderMap.remove(tag)?.release() // 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) { 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. * Sets up video decoders for registered surfaces.
*/ */
suspend fun onScrcpySessionStarted( suspend fun onScrcpySessionStarted(
session: ScrcpySessionInfo, session: Scrcpy.Session.SessionInfo,
sessionMgr: ScrcpySessionManager sessionMgr: Scrcpy.Session
) = sessionLifecycleMutex.withLock { ) = sessionLifecycleMutex.withLock {
this.session = sessionMgr
currentSessionInfo = session currentSessionInfo = session
releaseAllDecoders() releaseAllDecoders()
synchronized(bootstrapLock) { synchronized(bootstrapLock) {
@@ -148,6 +150,10 @@ class NativeCoreFacade(private val appContext: Context) {
} }
surfaceMap.forEach { (tag, surface) -> 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") Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag")
createOrReplaceDecoder(tag, surface, session) 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}" "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)) { if (!surfaceIdentityMap.containsKey(tag)) {
return@forEach return@forEach
} }
// Double-check decoder is still in map before feeding
if (decoderMap[tag] != decoder) {
return@forEach
}
runCatching { runCatching {
decoder.feedAnnexB( decoder.feedAnnexB(
packet.data, packet.data,
@@ -185,6 +197,7 @@ class NativeCoreFacade(private val appContext: Context) {
* Cleans up decoders and resets state. * Cleans up decoders and resets state.
*/ */
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock { suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
session = null
releaseAllDecoders() releaseAllDecoders()
synchronized(bootstrapLock) { synchronized(bootstrapLock) {
bootstrapPackets.clear() bootstrapPackets.clear()
@@ -202,7 +215,7 @@ class NativeCoreFacade(private val appContext: Context) {
fun get(context: Context): NativeCoreFacade { fun get(context: Context): NativeCoreFacade {
return instance ?: synchronized(this) { 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 * - Newly created decoders are fed with any cached bootstrap packets to allow
* faster playback startup. * faster playback startup.
*/ */
private fun createOrReplaceDecoder(tag: String, surface: Surface, session: ScrcpySessionInfo) { private fun createOrReplaceDecoder(tag: String, surface: Surface, session: Scrcpy.Session.SessionInfo) {
decoderMap.remove(tag)?.release() if (!surface.isValid) {
val mime = when (session.codec.lowercase()) { Log.w(TAG, "createOrReplaceDecoder(): skip invalid surface for tag=$tag")
"h264" -> "video/avc" return
"h265" -> "video/hevc"
"av1" -> "video/av01"
else -> "video/avc"
} }
decoderMap.remove(tag)?.release()
Log.i( Log.i(
TAG, 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( val decoder = AnnexBDecoder(
width = session.width, width = session.width,
height = session.height, height = session.height,
outputSurface = surface, outputSurface = surface,
mimeType = mime, mimeType = when (session.codecName.lowercase()) {
"h264" -> "video/avc"
"h265" -> "video/hevc"
"av1" -> "video/av01"
else -> "video/avc"
},
onOutputSizeChanged = { width, height -> onOutputSizeChanged = { width, height ->
val current = currentSessionInfo val current = currentSessionInfo
if (current == null || (current.width == width && current.height == height)) { 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( val cached = CachedPacket(
data = packet.data.copyOf(), data = packet.data.copyOf(),
ptsUs = packet.ptsUs, ptsUs = packet.ptsUs,
@@ -343,7 +359,7 @@ class NativeCoreFacade(private val appContext: Context) {
* isolated with `runCatching` so one codec failure doesn't stop others. * isolated with `runCatching` so one codec failure doesn't stop others.
*/ */
@Deprecated("TODO: Determine if this is really unnecessary") @Deprecated("TODO: Determine if this is really unnecessary")
private suspend fun ensureVideoConsumerAttached(sessionMgr: ScrcpySessionManager) { private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) {
sessionMgr.attachVideoConsumer { packet -> sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet) cacheBootstrapPacket(packet)
packetCount += 1 packetCount += 1
@@ -353,10 +369,16 @@ class NativeCoreFacade(private val appContext: Context) {
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" "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)) { if (!surfaceIdentityMap.containsKey(tag)) {
return@forEach return@forEach
} }
// Double-check decoder is still in map before feeding
if (decoderMap[tag] != decoder) {
return@forEach
}
runCatching { runCatching {
decoder.feedAnnexB( decoder.feedAnnexB(
packet.data, packet.data,
@@ -375,12 +397,4 @@ class NativeCoreFacade(private val appContext: Context) {
} }
decoderMap.clear() 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 package io.github.miuzarte.scrcpyforandroid.constants
object ScrcpyPresets { /**
val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) // px * A generic preset class that holds a list of preset values and provides
val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120) * methods to find the index of a value or its nearest match.
val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) // Kbps *
* @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 host: String,
val port: Int = Defaults.ADB_PORT, val port: Int = Defaults.ADB_PORT,
) { ) {
fun marshalToString(): String = "$host:$port" override fun toString(): String = "$host:$port"
companion object { companion object {
fun unmarshalFrom(s: String): ConnectionTarget? { 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 private var released = false
init { init {
if (!outputSurface.isValid) {
throw IllegalStateException("Cannot initialize decoder: output surface is not valid")
}
val format = MediaFormat.createVideoFormat(mimeType, width, height) val format = MediaFormat.createVideoFormat(mimeType, width, height)
if (sps != null) { if (sps != null) {
format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(sps)) 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.os.Build
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import io.github.miuzarte.scrcpyforandroid.storage.AdbClientData
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking 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() { override fun close() {
if (!closed) { if (!closed) {

View File

@@ -2,8 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.nio.file.Path import java.nio.file.Path
import kotlin.time.Duration 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 * Methods use Mutex for thread-safety because the underlying transport is single-connection
* and may be accessed from multiple coroutines. * and may be accessed from multiple coroutines.
*
* All network operations are executed on Dispatchers.IO.
*/ */
class NativeAdbService(appContext: Context) { class NativeAdbService(appContext: Context) {
private val transport = DirectAdbTransport(appContext) private val transport = DirectAdbTransport(appContext)
@@ -82,35 +86,39 @@ class NativeAdbService(appContext: Context) {
host: String, host: String,
port: Int, port: Int,
timeout: Duration = Duration.INFINITE, timeout: Duration = Duration.INFINITE,
) = mutex.withLock { ) = withContext(Dispatchers.IO) {
Log.i(TAG, "connect(): host=$host port=$port") mutex.withLock {
Log.i(TAG, "connect(): host=$host port=$port")
if (connection != null if (connection != null
&& connection!!.isAlive() && connection!!.isAlive()
&& connectedHost == host && connectedHost == host
&& connectedPort == port && connectedPort == port
) { ) {
return@withLock return@withLock
} }
disconnectInternal() disconnectInternal()
try { try {
val conn = withTimeout(timeout) { transport.connect(host, port) } val conn = withTimeout(timeout) { transport.connect(host, port) }
connection = conn connection = conn
connectedHost = host connectedHost = host
connectedPort = port connectedPort = port
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "connect(): failed host=$host port=$port", e) Log.e(TAG, "connect(): failed host=$host port=$port", e)
val detail = e.message ?: "${e.javaClass.simpleName} (no message)" val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e) throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e)
}
} }
} }
/** /**
* Close the current ADB connection immediately. * Close the current ADB connection immediately.
*/ */
suspend fun disconnect() = mutex.withLock { suspend fun disconnect() = withContext(Dispatchers.IO) {
disconnectInternal() mutex.withLock {
disconnectInternal()
}
} }
suspend fun isConnected(): Boolean = mutex.withLock { 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. * Execute a shell command on the connected device and return stdout text.
*/ */
suspend fun shell(command: String): String = mutex.withLock { 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 { suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock {
@@ -136,7 +146,9 @@ class NativeAdbService(appContext: Context) {
requireConnection().openStream("localabstract:$name") requireConnection().openStream("localabstract:$name")
} }
suspend fun close() = disconnect() suspend fun close() {
disconnect()
}
private fun disconnectInternal() { private fun disconnectInternal() {
runCatching { connection?.close() } 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 package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.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 io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState 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 top.yukonga.miuix.kmp.extra.SuperSwitch
import kotlin.math.roundToInt 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 @Composable
internal fun AdvancedConfigPage( internal fun AdvancedConfigPage(
contentPadding: PaddingValues, contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
cameraSizeOptions: SnapshotStateList<String>, scrcpy: Scrcpy,
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,
) { ) {
val scrcpyOptions = Storage.scrcpyOptions val scrcpyOptions = Storage.scrcpyOptions
val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var refreshBusy by remember { mutableStateOf(false) }
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 },
)
}
}
// TODO: handle custom value // TODO: handle custom value
// TODO: handle empty input // TODO: handle empty input
@@ -126,100 +72,164 @@ internal fun AdvancedConfigPage(
var video by scrcpyOptions.video.asMutableState() var video by scrcpyOptions.video.asMutableState()
var videoSource by scrcpyOptions.videoSource.asMutableState() var videoSource by scrcpyOptions.videoSource.asMutableState()
val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second } val videoSourceItems = remember { Shared.VideoSource.entries.map { it.string } }
val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst { val videoSourceIndex = remember(videoSource) {
it.first == videoSource Shared.VideoSource.entries.indexOfFirst { it.string == videoSource }.coerceAtLeast(0)
}.let { if (it >= 0) it else 0 } }
var displayId by scrcpyOptions.displayId.asMutableState() var displayId by scrcpyOptions.displayId.asMutableState()
var cameraId by scrcpyOptions.cameraId.asMutableState() var cameraId by scrcpyOptions.cameraId.asMutableState()
var cameraFacing by scrcpyOptions.cameraFacing.asMutableState() var cameraFacing by scrcpyOptions.cameraFacing.asMutableState()
val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second } val cameraFacingItems = remember {
val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst { listOf("默认") + Shared.CameraFacing.entries.drop(1).map { it.string }
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 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 cameraAr by scrcpyOptions.cameraAr.asMutableState()
var cameraFps by scrcpyOptions.cameraFps.asMutableState() var cameraFps by scrcpyOptions.cameraFps.asMutableState()
val cameraFpsPresetIndex = presetIndexFromInputForAdvancedPage( val cameraFpsPresetIndex = ScrcpyPresets.CameraFps.indexOfOrNearest(cameraFps)
cameraFps, CAMERA_FPS_PRESETS
)
var cameraHighSpeed by scrcpyOptions.cameraHighSpeed.asMutableState() var cameraHighSpeed by scrcpyOptions.cameraHighSpeed.asMutableState()
var audioSource by scrcpyOptions.audioSource.asMutableState() var audioSource by scrcpyOptions.audioSource.asMutableState()
val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second } val audioSourceItems = remember {
val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst { Shared.AudioSource.entries.map { it.string }
it.first == audioSource }
}.let { if (it >= 0) it else 0 } val audioSourceIndex = remember(audioSource) {
Shared.AudioSource.entries.indexOfFirst { it.string == audioSource }.coerceAtLeast(0)
}
var audioDup by scrcpyOptions.audioDup.asMutableState() var audioDup by scrcpyOptions.audioDup.asMutableState()
var audioPlayback by scrcpyOptions.audioPlayback.asMutableState() var audioPlayback by scrcpyOptions.audioPlayback.asMutableState()
var requireAudio by scrcpyOptions.requireAudio.asMutableState() var requireAudio by scrcpyOptions.requireAudio.asMutableState()
var maxSize by scrcpyOptions.maxSize.asMutableState() var maxSize by scrcpyOptions.maxSize.asMutableState()
val maxSizePresetIndex = presetIndexFromInputForAdvancedPage( val maxSizePresetIndex = ScrcpyPresets.MaxSize.indexOfOrNearest(maxSize)
maxSize.toString(), ScrcpyPresets.MaxSize
)
var maxFps by scrcpyOptions.maxFps.asMutableState() var maxFps by scrcpyOptions.maxFps.asMutableState()
val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage( val maxFpsPresetIndex = ScrcpyPresets.MaxFPS.indexOfOrNearest(maxFps.toIntOrNull() ?: 0)
maxFps, ScrcpyPresets.MaxFPS
)
var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() var videoEncoder by scrcpyOptions.videoEncoder.asMutableState()
var videoCodecOptions by scrcpyOptions.videoCodecOptions.asMutableState() var videoCodecOptions by scrcpyOptions.videoCodecOptions.asMutableState()
var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() var audioEncoder by scrcpyOptions.audioEncoder.asMutableState()
var audioCodecOptions by scrcpyOptions.audioCodecOptions.asMutableState() var audioCodecOptions by scrcpyOptions.audioCodecOptions.asMutableState()
var newDisplayWidth by remember { val videoEncoderDropdownItems = remember(scrcpy.videoEncoders) {
mutableStateOf("") listOf("") + scrcpy.videoEncoders
} }
var newDisplayHeight by remember { val videoEncoderIndex = remember(videoEncoder, scrcpy.videoEncoders) {
mutableStateOf("") (scrcpy.videoEncoders.indexOf(videoEncoder) + 1).coerceAtLeast(0)
} }
var newDisplayDpi by remember { val audioEncoderDropdownItems = remember(scrcpy.audioEncoders) {
mutableStateOf("") 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>] // [<width>x<height>][/<dpi>]
// TODO: 填充当前值到输入框
var newDisplay by scrcpyOptions.newDisplay.asMutableState() 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() { fun updateNewDisplay() {
var nd = "" newDisplay = NewDisplay
if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) { .parseFrom(newDisplayWidth, newDisplayHeight, newDisplayDpi)
nd += "${newDisplayWidth}x${newDisplayHeight}" .toString()
}
if (newDisplayDpi.isNotBlank()) {
nd += "/$newDisplayDpi"
}
newDisplay = nd
} }
var cropWidth by remember {
mutableStateOf("")
}
var cropHeight by remember {
mutableStateOf("")
}
var cropX by remember {
mutableStateOf("")
}
var cropY by remember {
mutableStateOf("")
}
// width:height:x:y // width:height:x:y
// TODO: 填充当前值到输入框
var crop by scrcpyOptions.crop.asMutableState() var crop by scrcpyOptions.crop.asMutableState()
fun updateCrop(): Unit { val (cWidth, cHeight, cX, cY) = Crop.parseFrom(crop)
if (cropWidth.isNotBlank() var cropWidth by remember(crop) { mutableStateOf(cWidth?.toString() ?: "") }
&& cropHeight.isNotBlank() var cropHeight by remember(crop) { mutableStateOf(cHeight?.toString() ?: "") }
&& cropX.isNotBlank() var cropX by remember(crop) { mutableStateOf(cX?.toString() ?: "") }
&& cropY.isNotBlank() var cropY by remember(crop) { mutableStateOf(cY?.toString() ?: "") }
) crop = "$cropWidth:$cropHeight:$cropX:$cropY" 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( AppPageLazyColumn(
@@ -227,6 +237,15 @@ internal fun AdvancedConfigPage(
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) { ) {
item { item {
Card {
TextField(
value = serverParamsPreview,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
)
}
Card { Card {
SuperSwitch( SuperSwitch(
title = "启动后关闭屏幕", title = "启动后关闭屏幕",
@@ -265,13 +284,13 @@ internal fun AdvancedConfigPage(
items = videoSourceItems, items = videoSourceItems,
selectedIndex = videoSourceIndex, selectedIndex = videoSourceIndex,
onSelectedIndexChange = { onSelectedIndexChange = {
videoSource = VIDEO_SOURCE_OPTIONS[it].first videoSource = Shared.VideoSource.entries[it].string
}, },
) )
if (videoSource == "display") { AnimatedVisibility(videoSource == "display") {
TextField( TextField(
value = displayId.toString(), value = if (displayId == -1) "" else displayId.toString(),
onValueChange = { displayId = it.toInt() }, onValueChange = { displayId = it.toIntOrNull() ?: -1 },
label = "--display-id", label = "--display-id",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@@ -281,7 +300,7 @@ internal fun AdvancedConfigPage(
.padding(bottom = UiSpacing.CardContent), .padding(bottom = UiSpacing.CardContent),
) )
} }
if (videoSource == "camera") { AnimatedVisibility(videoSource == "camera") {
TextField( TextField(
value = cameraId, value = cameraId,
onValueChange = { cameraId = it }, onValueChange = { cameraId = it },
@@ -292,47 +311,95 @@ internal fun AdvancedConfigPage(
.padding(horizontal = UiSpacing.CardContent) .padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent), .padding(bottom = UiSpacing.CardContent),
) )
}
AnimatedVisibility(videoSource == "camera") {
SuperArrow( SuperArrow(
title = "重新获取 Camera Sizes", title = "重新获取 Camera Sizes",
summary = "--list-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( SuperDropdown(
title = "摄像头朝向", title = "摄像头朝向",
summary = "--camera-facing", summary = "--camera-facing",
items = cameraFacingItems, items = cameraFacingItems,
selectedIndex = cameraFacingIndex, selectedIndex = cameraFacingIndex,
onSelectedIndexChange = { onSelectedIndexChange = {
cameraFacing = CAMERA_FACING_OPTIONS[it].first cameraFacing =
if (it == 0) "" else Shared.CameraFacing.entries[it].string
}, },
) )
}
AnimatedVisibility(videoSource == "camera") {
SuperDropdown( SuperDropdown(
title = "摄像头分辨率", title = "摄像头分辨率",
summary = "--camera-size", summary = "--camera-size",
items = cameraSizeDropdownItems, items = cameraSizeDropdownItems,
selectedIndex = cameraSizeIndex.coerceIn( selectedIndex = cameraSizeDropdownIndex,
0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0)
),
onSelectedIndexChange = { onSelectedIndexChange = {
cameraSize = when (it) { cameraSizeDropdownIndex = it
0 -> "" cameraSizeUseCustom = it == 1
cameraSizeDropdownItems.lastIndex -> "custom" when (it) {
else -> cameraSizeDropdownItems[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, AnimatedVisibility(videoSource == "camera" && cameraSizeUseCustom) {
onValueChange = { cameraSize = it }, SuperTextField(
label = "--camera-size", value = cameraSizeCustomInput,
singleLine = true, onValueChange = { cameraSizeCustomInput = it },
modifier = Modifier onFocusLost = {
.fillMaxWidth() if (cameraSizeCustomInput in scrcpy.cameraSizes) {
.padding(horizontal = UiSpacing.CardContent) cameraSizeDropdownIndex =
.padding(bottom = UiSpacing.CardContent), 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( TextField(
value = cameraAr, value = cameraAr,
onValueChange = { cameraAr = it }, onValueChange = { cameraAr = it },
@@ -343,28 +410,37 @@ internal fun AdvancedConfigPage(
.padding(horizontal = UiSpacing.CardContent) .padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent), .padding(bottom = UiSpacing.CardContent),
) )
SuperSlide( }
AnimatedVisibility(videoSource == "camera") {
SuperSlider(
title = "摄像头帧率", title = "摄像头帧率",
summary = "--camera-fps", summary = "--camera-fps",
value = cameraFpsPresetIndex.toFloat(), value = cameraFpsPresetIndex.toFloat(),
onValueChange = { value -> onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex) val idx =
cameraFps = CAMERA_FPS_PRESETS[idx] value.roundToInt().coerceIn(0, ScrcpyPresets.CameraFps.lastIndex)
cameraFps = ScrcpyPresets.CameraFps[idx]
}, },
valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(), valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(),
steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0), steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0),
unit = "fps", unit = "fps",
zeroStateText = "默认", zeroStateText = "默认",
showUnitWhenZeroState = false, showUnitWhenZeroState = false,
showKeyPoints = true, showKeyPoints = true,
keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() }, keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() },
displayText = cameraFps.toString(), displayText = cameraFps.toString(),
inputHint = "0 或留空表示默认", inputHint = "0 或留空表示默认",
inputInitialValue = cameraFps.toString(), inputInitialValue = cameraFps.toString(),
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE, inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { cameraFps = it.toInt() }, onInputConfirm = { input ->
input.toIntOrNull()
?.let { cameraFps = it }
?: run { cameraFps = 0 }
},
) )
}
AnimatedVisibility(videoSource == "camera") {
SuperSwitch( SuperSwitch(
title = "高帧率模式", title = "高帧率模式",
summary = "--camera-high-speed", summary = "--camera-high-speed",
@@ -382,20 +458,10 @@ internal fun AdvancedConfigPage(
summary = "--audio-source", summary = "--audio-source",
items = audioSourceItems, items = audioSourceItems,
selectedIndex = audioSourceIndex, 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( SuperSwitch(
title = "音频双路输出", title = "音频双路输出",
summary = "--audio-dup", summary = "--audio-dup",
@@ -420,12 +486,13 @@ internal fun AdvancedConfigPage(
item { item {
Card { Card {
SuperSlide( SuperSlider(
title = "最大分辨率", title = "最大分辨率",
summary = "--max-size", summary = "--max-size",
value = maxSizePresetIndex.toFloat(), value = maxSizePresetIndex.toFloat(),
onValueChange = { value -> onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) val idx =
value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex)
maxSize = ScrcpyPresets.MaxSize[idx] maxSize = ScrcpyPresets.MaxSize[idx]
}, },
valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(),
@@ -439,16 +506,16 @@ internal fun AdvancedConfigPage(
inputHint = "0 或留空表示关闭", inputHint = "0 或留空表示关闭",
inputInitialValue = maxSize.toString(), inputInitialValue = maxSize.toString(),
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE, inputValueRange = 0f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { maxSize = it.toInt() }, onInputConfirm = { input -> input.toIntOrNull()?.let { maxSize = it } },
) )
SuperSlide( SuperSlider(
title = "最大帧率", title = "最大帧率",
summary = "--max-fps", summary = "--max-fps",
value = maxFpsPresetIndex.toFloat(), value = maxFpsPresetIndex.toFloat(),
onValueChange = { value -> onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) 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(), valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0),
@@ -461,7 +528,7 @@ internal fun AdvancedConfigPage(
inputHint = "0 或留空表示关闭", inputHint = "0 或留空表示关闭",
inputInitialValue = maxFps, inputInitialValue = maxFps,
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE, inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { maxFps = it }, onInputConfirm = { maxFps = it },
) )
} }
@@ -472,14 +539,29 @@ internal fun AdvancedConfigPage(
SuperArrow( SuperArrow(
title = "重新获取编码器列表", title = "重新获取编码器列表",
summary = "--list-encoders", 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( SuperSpinner(
title = "视频编码器", title = "视频编码器",
summary = "--video-encoder", summary = "--video-encoder",
items = videoEncoderEntries, items = videoEncoderEntries,
selectedIndex = videoEncoderIndex, selectedIndex = videoEncoderIndex,
onSelectedIndexChange = { videoEncoder = videoEncoderEntries[it].title ?: "" }, onSelectedIndexChange = {
videoEncoder = videoEncoderEntries[it].title ?: ""
},
) )
TextField( TextField(
value = videoCodecOptions, value = videoCodecOptions,
@@ -496,7 +578,9 @@ internal fun AdvancedConfigPage(
summary = "--audio-encoder", summary = "--audio-encoder",
items = audioEncoderEntries, items = audioEncoderEntries,
selectedIndex = audioEncoderIndex, selectedIndex = audioEncoderIndex,
onSelectedIndexChange = { audioEncoder = audioEncoderEntries[it].title ?: "" }, onSelectedIndexChange = {
audioEncoder = audioEncoderEntries[it].title ?: ""
},
) )
TextField( TextField(
value = audioCodecOptions, value = audioCodecOptions,
@@ -531,9 +615,9 @@ internal fun AdvancedConfigPage(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) { ) {
TextField( TextField(
label = "width",
value = newDisplayWidth, value = newDisplayWidth,
onValueChange = { newDisplayWidth = it; updateNewDisplay() }, onValueChange = { newDisplayWidth = it; updateNewDisplay() },
label = "width",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
@@ -545,9 +629,9 @@ internal fun AdvancedConfigPage(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextField( TextField(
label = "height",
value = newDisplayHeight, value = newDisplayHeight,
onValueChange = { newDisplayHeight = it; updateNewDisplay() }, onValueChange = { newDisplayHeight = it; updateNewDisplay() },
label = "height",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
@@ -559,9 +643,9 @@ internal fun AdvancedConfigPage(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextField( TextField(
label = "dpi",
value = newDisplayDpi, value = newDisplayDpi,
onValueChange = { newDisplayDpi = it; updateNewDisplay() }, onValueChange = { newDisplayDpi = it; updateNewDisplay() },
label = "dpi",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
@@ -600,9 +684,9 @@ internal fun AdvancedConfigPage(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) { ) {
TextField( TextField(
value = cropWidth,
onValueChange = { cropWidth = it },
label = "width", label = "width",
value = cropWidth,
onValueChange = { cropWidth = it; updateCrop() },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
@@ -614,9 +698,9 @@ internal fun AdvancedConfigPage(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextField( TextField(
value = cropHeight,
onValueChange = { cropHeight = it },
label = "height", label = "height",
value = cropHeight,
onValueChange = { cropHeight = it; updateCrop() },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
@@ -633,9 +717,9 @@ internal fun AdvancedConfigPage(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) { ) {
TextField( TextField(
value = cropX,
onValueChange = { cropX = it },
label = "x", label = "x",
value = cropX,
onValueChange = { cropX = it; updateCrop() },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
@@ -647,9 +731,9 @@ internal fun AdvancedConfigPage(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextField( TextField(
value = cropY,
onValueChange = { cropY = it },
label = "y", label = "y",
value = cropY,
onValueChange = { cropY = it; updateCrop() },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
@@ -669,27 +753,3 @@ internal fun AdvancedConfigPage(
item { Spacer(Modifier.height(UiSpacing.BottomContent)) } 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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade 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.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics 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.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy 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
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo 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 io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -60,47 +56,14 @@ import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.extra.SuperBottomSheet import top.yukonga.miuix.kmp.extra.SuperBottomSheet
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket 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_INTERVAL_MS = 3_000L
private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L 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_DISCOVER_TIMEOUT_MS = 2_000L
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_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 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 @Composable
fun DeviceTabScreen( fun DeviceTabScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
@@ -109,23 +72,12 @@ fun DeviceTabScreen(
scrcpy: Scrcpy, scrcpy: Scrcpy,
snack: SnackbarHostState, snack: SnackbarHostState,
scrollBehavior: ScrollBehavior, 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, onSessionStartedChange: (Boolean) -> Unit,
onRefreshEncodersActionChange: ((() -> Unit)?) -> Unit, onClearLogsActionChange: ((() -> Unit)) -> Unit,
onRefreshCameraSizesActionChange: ((() -> Unit)?) -> Unit,
onClearLogsActionChange: ((() -> Unit)?) -> Unit,
onCanClearLogsChange: (Boolean) -> Unit, onCanClearLogsChange: (Boolean) -> Unit,
onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit, onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit,
onOpenAdvancedPage: () -> Unit, onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
) { ) {
val appSettings = Storage.appSettings val appSettings = Storage.appSettings
val quickDevices = Storage.quickDevices val quickDevices = Storage.quickDevices
@@ -141,24 +93,14 @@ fun DeviceTabScreen(
} }
// val initialSettings = remember(context) { loadDevicePageSettings(context) } // val initialSettings = remember(context) { loadDevicePageSettings(context) }
val scope = rememberCoroutineScope() 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. // Run adb operations on a dedicated single thread.
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. // Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
DisposableEffect(adbWorkerDispatcher) {
onDispose {
adbWorkerDispatcher.close()
}
}
var busy by rememberSaveable { mutableStateOf(false) } var busy by rememberSaveable { mutableStateOf(false) }
var statusLine by rememberSaveable { mutableStateOf("未连接") } var statusLine by rememberSaveable { mutableStateOf("未连接") }
var adbConnected by rememberSaveable { mutableStateOf(false) } var adbConnected by rememberSaveable { mutableStateOf(false) }
var isQuickConnected by rememberSaveable { mutableStateOf(false) }
var currentTargetHost by rememberSaveable { mutableStateOf("") } var currentTargetHost by rememberSaveable { mutableStateOf("") }
var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) } var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) }
var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") } var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") }
@@ -168,7 +110,7 @@ fun DeviceTabScreen(
var sessionInfoCodec by rememberSaveable { mutableStateOf("") } var sessionInfoCodec by rememberSaveable { mutableStateOf("") }
var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) }
var sessionInfo by remember { var sessionInfo by remember {
mutableStateOf<ScrcpySessionInfo?>(null) mutableStateOf<Scrcpy.Session.SessionInfo?>(null)
} }
LaunchedEffect( LaunchedEffect(
sessionInfoWidth, sessionInfoWidth,
@@ -178,12 +120,16 @@ fun DeviceTabScreen(
sessionInfoControlEnabled sessionInfoControlEnabled
) { ) {
sessionInfo = if (sessionInfoDeviceName.isNotBlank()) { sessionInfo = if (sessionInfoDeviceName.isNotBlank()) {
ScrcpySessionInfo( Scrcpy.Session.SessionInfo(
width = sessionInfoWidth, width = sessionInfoWidth,
height = sessionInfoHeight, height = sessionInfoHeight,
deviceName = sessionInfoDeviceName, deviceName = sessionInfoDeviceName,
codec = sessionInfoCodec, codecId = 0,
codecName = sessionInfoCodec,
audioCodecId = 0,
controlEnabled = sessionInfoControlEnabled, controlEnabled = sessionInfoControlEnabled,
host = currentTargetHost,
port = currentTargetPort,
) )
} else { } else {
null null
@@ -202,7 +148,7 @@ fun DeviceTabScreen(
if (currentTargetHost.isNotBlank()) if (currentTargetHost.isNotBlank())
ConnectionTarget( ConnectionTarget(
currentTargetHost, currentTargetHost,
currentTargetPort currentTargetPort,
) else null ) else null
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() } val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
@@ -212,11 +158,11 @@ fun DeviceTabScreen(
} }
var quickDevicesList by quickDevices.quickDevicesList.asMutableState() var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
var savedShortcuts = rememberSaveable(quickDevicesList, saver = DeviceShortcutsSaver) { var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) }
DeviceShortcuts.unmarshalFrom(quickDevicesList)
}
var quickConnectInput by quickDevices.quickConnectInput.asMutableState()
LaunchedEffect(quickDevicesList) {
savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList)
}
// save changes when [savedShortcuts] was modified // save changes when [savedShortcuts] was modified
LaunchedEffect(savedShortcuts) { LaunchedEffect(savedShortcuts) {
val serialized = savedShortcuts.marshalToString() 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. * Disconnect the current ADB connection and stop any running scrcpy session.
* *
* Concurrency / thread boundary: * 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. * - This ensures UI coroutines are never blocked by synchronous native I/O.
* *
* Side effects: * 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`, * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`,
* `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`.
* - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided. * - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided.
@@ -252,11 +201,9 @@ fun DeviceTabScreen(
logMessage: String? = null, logMessage: String? = null,
showSnackMessage: String? = null, showSnackMessage: String? = null,
) { ) {
withContext(adbWorkerDispatcher) { // Also stops scrcpy.
// Also stops scrcpy. runCatching { scrcpy.stop() }
runCatching { scrcpy.stop() } runCatching { adbService.disconnect() }
runCatching { adbService.disconnect() }
}
adbConnected = false adbConnected = false
currentTargetHost = "" currentTargetHost = ""
currentTargetPort = Defaults.ADB_PORT currentTargetPort = Defaults.ADB_PORT
@@ -326,10 +273,8 @@ fun DeviceTabScreen(
* indefinitely. Use a small, caller-chosen timeout to keep UX snappy. * indefinitely. Use a small, caller-chosen timeout to keep UX snappy.
*/ */
suspend fun connectWithTimeout(host: String, port: Int) { suspend fun connectWithTimeout(host: String, port: Int) {
return withContext(adbWorkerDispatcher) { return withTimeout(ADB_CONNECT_TIMEOUT_MS) {
withTimeout(ADB_CONNECT_TIMEOUT_MS) { adbService.connect(host, port)
adbService.connect(host, port)
}
} }
} }
@@ -347,17 +292,12 @@ fun DeviceTabScreen(
* echo check helps detect that state. * echo check helps detect that state.
*/ */
suspend fun keepAliveCheck(host: String, port: Int): Boolean { suspend fun keepAliveCheck(host: String, port: Int): Boolean {
return withContext(adbWorkerDispatcher) { return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { val connected = adbService.isConnected()
val connected = adbService.isConnected() if (!connected) {
if (!connected) { return@withTimeout false
return@withTimeout false
}
runCatching {
adbService.shell("echo -n 1")
true
}.getOrElse { false }
} }
return@withTimeout true
} }
} }
@@ -391,7 +331,7 @@ fun DeviceTabScreen(
fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
// For non-adb actions (start/stop/pair/list refresh...). // For non-adb actions (start/stop/pair/list refresh...).
if (busy) return if (busy) return
scope.launch { scope.launch(kotlinx.coroutines.Dispatchers.IO) {
busy = true busy = true
try { try {
block() block()
@@ -425,10 +365,16 @@ fun DeviceTabScreen(
* UI controls remain actionable while background retries occur. * UI controls remain actionable while background retries occur.
* - Errors and timeouts are logged and surfaced similarly to `runBusy`. * - 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. // For manual adb operations from user actions.
if (adbConnecting) return if (adbConnecting) return
scope.launch { scope.launch {
onStarted?.invoke()
adbConnecting = true adbConnecting = true
try { try {
block() block()
@@ -465,35 +411,23 @@ fun DeviceTabScreen(
suspend fun refreshEncoderLists() { suspend fun refreshEncoderLists() {
if (!adbConnected) return if (!adbConnected) return
runCatching { runCatching {
scrcpy.listOptions(list = ListOptions.ENCODERS) scrcpy.refreshEncoders()
}.onSuccess { result -> }.onSuccess {
val lists = result as Scrcpy.ListResult.Encoders // Validate current selections
onVideoEncoderOptionsChange(lists.videoEncoders) if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) {
onAudioEncoderOptionsChange(lists.audioEncoders)
onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
onAudioEncoderTypeMapChange(lists.audioEncoderTypes)
if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) {
videoEncoder = "" videoEncoder = ""
} }
if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) {
audioEncoder = "" audioEncoder = ""
} }
logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}")
if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) {
logEvent( logEvent(
"提示: 编码器为空,请检查 server 路径/版本与设备系统日志", "提示: 编码器为空,请检查 server 路径/版本与设备系统日志",
Log.WARN Log.WARN
) )
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
if (preview.isNotBlank()) {
logEvent("编码器原始输出: $preview", Log.DEBUG)
}
} }
}.onFailure { e -> }.onFailure { e ->
onVideoEncoderOptionsChange(emptyList())
onAudioEncoderOptionsChange(emptyList())
onVideoEncoderTypeMapChange(emptyMap())
onAudioEncoderTypeMapChange(emptyMap())
logEvent( logEvent(
"读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", "读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}",
Log.ERROR, Log.ERROR,
@@ -507,22 +441,14 @@ fun DeviceTabScreen(
suspend fun refreshCameraSizeLists() { suspend fun refreshCameraSizeLists() {
if (!adbConnected) return if (!adbConnected) return
runCatching { runCatching {
scrcpy.listOptions(ListOptions.CAMERA_SIZES) scrcpy.refreshCameraSizes()
}.onSuccess { result -> }.onSuccess {
val lists = result as Scrcpy.ListResult.CameraSizes // Validate current selection
onCameraSizeOptionsChange(lists.sizes) if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) {
if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) {
cameraSize = "" cameraSize = ""
} }
logEvent("camera sizes 已刷新: count=${lists.sizes.size}") logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}")
if (lists.sizes.isEmpty()) {
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
if (preview.isNotBlank()) {
logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
}
}
}.onFailure { e -> }.onFailure { e ->
onCameraSizeOptionsChange(emptyList())
logEvent( logEvent(
"读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", "读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}",
Log.ERROR, Log.ERROR,
@@ -738,7 +664,7 @@ fun DeviceTabScreen(
sessionInfoWidth = sessionInfo?.width ?: 0 sessionInfoWidth = sessionInfo?.width ?: 0
sessionInfoHeight = sessionInfo?.height ?: 0 sessionInfoHeight = sessionInfo?.height ?: 0
sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty()
sessionInfoCodec = sessionInfo?.codec.orEmpty() sessionInfoCodec = sessionInfo?.codecName.orEmpty()
sessionInfoControlEnabled = sessionInfo?.controlEnabled == true sessionInfoControlEnabled = sessionInfo?.controlEnabled == true
} else { } else {
sessionInfoWidth = 0 sessionInfoWidth = 0
@@ -751,12 +677,6 @@ fun DeviceTabScreen(
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onRefreshEncodersActionChange {
runBusy("刷新编码器") { refreshEncoderLists() }
}
onRefreshCameraSizesActionChange {
runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() }
}
onClearLogsActionChange { onClearLogsActionChange {
EventLogger.clearLogs() EventLogger.clearLogs()
} }
@@ -764,11 +684,8 @@ fun DeviceTabScreen(
showReorderSheet = true showReorderSheet = true
} }
onDispose { onDispose {
onRefreshEncodersActionChange(null) // canClearLogs 是 DevicePage 特有的状态,需要重置
onRefreshCameraSizesActionChange(null)
onClearLogsActionChange(null)
onCanClearLogsChange(false) onCanClearLogsChange(false)
onOpenReorderDevicesActionChange(null)
} }
} }
@@ -800,8 +717,8 @@ fun DeviceTabScreen(
fun sendVirtualButtonAction(action: VirtualButtonAction) { fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") { runBusy("发送 ${action.title}") {
nativeCore.sessionManager.injectKeycode(0, keycode) nativeCore.session?.injectKeycode(0, keycode)
nativeCore.sessionManager.injectKeycode(1, keycode) nativeCore.session?.injectKeycode(1, keycode)
} }
} }
@@ -870,14 +787,19 @@ fun DeviceTabScreen(
onAction = { onAction = {
haptics.contextClick() haptics.contextClick()
if (!isConnectedTarget) { if (!isConnectedTarget) {
activeDeviceActionId = device.id runAdbConnect(
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { "连接 ADB",
onStarted = { activeDeviceActionId = device.id },
onFinished = { activeDeviceActionId = null },
) {
disconnectCurrentTargetBeforeConnecting(host, port) disconnectCurrentTargetBeforeConnecting(host, port)
try { try {
connectWithTimeout(host, port) connectWithTimeout(host, port)
adbConnected = true adbConnected = true
savedShortcuts = isQuickConnected = false // 标记为快速设备连接
savedShortcuts.update(host = host, port = port, online = true) savedShortcuts = savedShortcuts.update(
host = host, port = port, online = true
)
handleAdbConnected(host, port) handleAdbConnected(host, port)
} catch (e: Exception) { } catch (e: Exception) {
statusLine = "ADB 连接失败" statusLine = "ADB 连接失败"
@@ -889,7 +811,11 @@ fun DeviceTabScreen(
} }
} else { } else {
activeDeviceActionId = device.id activeDeviceActionId = device.id
runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) { runAdbConnect(
"断开 ADB",
onStarted = { activeDeviceActionId = device.id },
onFinished = { activeDeviceActionId = null },
) {
sessionReconnectBlacklistHosts += host sessionReconnectBlacklistHosts += host
disconnectAdbConnection( disconnectAdbConnection(
clearQuickOnlineForTarget = ConnectionTarget(host, port), clearQuickOnlineForTarget = ConnectionTarget(host, port),
@@ -905,28 +831,37 @@ fun DeviceTabScreen(
if (!adbConnected) item { if (!adbConnected) item {
// "快速连接" // "快速连接"
QuickConnectCard( QuickConnectCard(
input = quickConnectInput, input = quickConnectInputTemp,
onValueChange = { quickConnectInput = it }, onValueChange = {
quickConnectInputTemp = it
quickConnectInput = quickConnectInputTemp
},
// onFocusChange = { quickConnectInput = quickConnectInputTemp },
enabled = !adbConnecting, enabled = !adbConnecting,
onAddDevice = { onAddDevice = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInput) val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
?: return@QuickConnectCard ?: return@QuickConnectCard
savedShortcuts = savedShortcuts.upsert( savedShortcuts = savedShortcuts.upsert(
DeviceShortcut(host = target.host, port = target.port) 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 { scope.launch {
snack.showSnackbar("已添加设备: ${target.host}:${target.port}") snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
} }
}, },
onConnect = { onConnect = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInput) val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
?: return@QuickConnectCard ?: return@QuickConnectCard
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { runAdbConnect(
"连接 ADB",
onStarted = { activeDeviceActionId = target.toString() },
onFinished = { activeDeviceActionId = null },
) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port) disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try { try {
connectWithTimeout(target.host, target.port) connectWithTimeout(target.host, target.port)
adbConnected = true adbConnected = true
isQuickConnected = true // 标记为快速连接
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = target.host, host = target.host,
port = target.port, port = target.port,
@@ -956,7 +891,7 @@ fun DeviceTabScreen(
onPair = { host, port, code -> onPair = { host, port, code ->
runBusy("执行配对") { runBusy("执行配对") {
val resolvedHost = host.trim() val resolvedHost = host.trim()
val resolvedPort = port.toIntOrNull() ?: return@runBusy val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim() val resolvedCode = code.trim()
val ok = adbService.pair( val ok = adbService.pair(
resolvedHost, resolvedHost,
@@ -980,26 +915,30 @@ fun DeviceTabScreen(
ConfigPanel( ConfigPanel(
busy = busy, busy = busy,
audioForwardingSupported = audioForwardingSupported, audioForwardingSupported = audioForwardingSupported,
cameraMirroringSupported = cameraMirroringSupported,
onOpenAdvanced = onOpenAdvancedPage, onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.contextClick() }, onStartStopHaptic = { haptics.contextClick() },
onStart = { onStart = {
runBusy("启动 scrcpy") { runBusy("启动 scrcpy") {
val options = scrcpyOptions.toClientOptions() val options = scrcpyOptions.toClientOptions()
val session = scrcpy.start(options) val session = scrcpy.start(options)
sessionInfo = session sessionInfo = session.copy(
host = currentTargetHost,
port = currentTargetPort
)
statusLine = "scrcpy 运行中" statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
val videoDetail = if (!options.video) { val videoDetail = if (!options.video) {
"off" "off"
} else { } else {
"${session.codec} ${session.width}x${session.height} " + "${session.codecName} ${session.width}x${session.height} " +
"@${String.format("%.1f", videoBitRate / 1_000_000)}Mbps" "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
} }
val audioDetail = if (!audio) { val audioDetail = if (!audio) {
"off" "off"
} else { } else {
val playback = if (!options.audioPlayback) "(no-playback)" 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( logEvent(
"scrcpy 已启动: device=${session.deviceName}" + "scrcpy 已启动: device=${session.deviceName}" +
@@ -1010,9 +949,6 @@ fun DeviceTabScreen(
scope.launch { scope.launch {
snack.showSnackbar("scrcpy 已启动") snack.showSnackbar("scrcpy 已启动")
} }
scrcpy.getLastServerCommand()?.let { command ->
logEvent("scrcpy-server args: $command")
}
} }
}, },
onStop = { 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) LogsPanel(lines = EventLogger.eventLog)
} }
// TODO: 放进 [AppPageLazyColumn] 里
item { Spacer(Modifier.height(UiSpacing.BottomContent)) } 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.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade 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.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -68,11 +68,13 @@ fun FullscreenControlPage(
} }
var session by remember(launch) { var session by remember(launch) {
mutableStateOf( mutableStateOf(
ScrcpySessionInfo( Scrcpy.Session.SessionInfo(
width = launch.width, width = launch.width,
height = launch.height, height = launch.height,
deviceName = launch.deviceName.ifBlank { "设备" }, deviceName = launch.deviceName.ifBlank { "设备" },
codec = launch.codec.ifBlank { "unknown" }, codecId = 0,
codecName = launch.codec.ifBlank { "unknown" },
audioCodecId = 0,
controlEnabled = true, controlEnabled = true,
), ),
) )
@@ -121,8 +123,12 @@ fun FullscreenControlPage(
} }
suspend fun sendKeycode(keycode: Int) { suspend fun sendKeycode(keycode: Int) {
nativeCore.sessionManager.injectKeycode(0, keycode) runCatching {
nativeCore.sessionManager.injectKeycode(1, keycode) 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 -> Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
@@ -139,7 +145,7 @@ fun FullscreenControlPage(
currentFps = currentFps, currentFps = currentFps,
enableBackHandler = false, enableBackHandler = false,
onInjectTouch = { action, pointerId, x, y, pressure, buttons -> onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
nativeCore.sessionManager.injectTouch( nativeCore.session?.injectTouch(
action = action, action = action,
pointerId = pointerId, pointerId = pointerId,
x = x, x = x,

View File

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

View File

@@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent import android.content.Intent
import android.os.Process import android.os.Process
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.Clear
import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -22,13 +26,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn 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.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.IconButton
@@ -77,6 +83,10 @@ fun SettingsScreen(
) { ) {
val appContext = LocalContext.current.applicationContext val appContext = LocalContext.current.applicationContext
val appSettings = Storage.appSettings val appSettings = Storage.appSettings
var needMigration by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
needMigration = PreferenceMigration(appContext).needsMigration()
}
val context = LocalContext.current val context = LocalContext.current
@@ -98,9 +108,22 @@ fun SettingsScreen(
// scrcpy-server // scrcpy-server
var customServerUri by appSettings.customServerUri.asMutableState() var customServerUri by appSettings.customServerUri.asMutableState()
var serverRemotePath by appSettings.serverRemotePath.asMutableState() var serverRemotePath by appSettings.serverRemotePath.asMutableState()
var serverRemotePathInput by rememberSaveable {
mutableStateOf(
// 默认值留空显示为 hint
if (runBlocking { appSettings.serverRemotePath.isDefaultValue() }) ""
else serverRemotePath
)
}
// ADB // ADB
var adbKeyName by appSettings.adbKeyName.asMutableState() 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 adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState()
var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState() var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState()
@@ -141,7 +164,7 @@ fun SettingsScreen(
checked = keepScreenOnWhenStreaming, checked = keepScreenOnWhenStreaming,
onCheckedChange = { keepScreenOnWhenStreaming = it }, onCheckedChange = { keepScreenOnWhenStreaming = it },
) )
SuperSlide( SuperSlider(
title = "预览卡高度", title = "预览卡高度",
summary = "设备页预览卡高度", summary = "设备页预览卡高度",
value = devicePreviewCardHeightDp.toFloat(), value = devicePreviewCardHeightDp.toFloat(),
@@ -149,12 +172,12 @@ fun SettingsScreen(
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
}, },
valueRange = 160f..600f, valueRange = 160f..600f,
steps = 439, steps = 600-160-1,
unit = "dp", unit = "dp",
displayFormatter = { it.roundToInt().toString() }, displayFormatter = { it.roundToInt().toString() },
inputInitialValue = devicePreviewCardHeightDp.toString(), inputInitialValue = devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..Float.MAX_VALUE, inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { input -> onInputConfirm = { input ->
input.toIntOrNull()?.let { input.toIntOrNull()?.let {
devicePreviewCardHeightDp = it.coerceAtLeast(120) devicePreviewCardHeightDp = it.coerceAtLeast(120)
@@ -218,9 +241,13 @@ fun SettingsScreen(
.padding(bottom = UiSpacing.FieldLabelBottom), .padding(bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
TextField( SuperTextField(
value = serverRemotePath, value = serverRemotePathInput,
onValueChange = { serverRemotePath = it }, onValueChange = { serverRemotePathInput = it },
onFocusLost = {
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
serverRemotePathInput = ""
},
label = AppSettings.SERVER_REMOTE_PATH.defaultValue, label = AppSettings.SERVER_REMOTE_PATH.defaultValue,
useLabelAsPlaceholder = true, useLabelAsPlaceholder = true,
singleLine = true, singleLine = true,
@@ -240,9 +267,13 @@ fun SettingsScreen(
.padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom), .padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
TextField( SuperTextField(
value = adbKeyName, value = adbKeyNameInput,
onValueChange = { adbKeyName = it }, onValueChange = { adbKeyNameInput = it },
onFocusLost = {
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
adbKeyNameInput = ""
},
label = AppSettings.ADB_KEY_NAME.defaultValue, label = AppSettings.ADB_KEY_NAME.defaultValue,
useLabelAsPlaceholder = true, useLabelAsPlaceholder = true,
singleLine = true, singleLine = true,
@@ -265,35 +296,41 @@ fun SettingsScreen(
) )
} }
SectionSmallTitle("应用") // 这部分应该不会显示出来,
Card { // 应用启动时就会执行迁移与旧数据的删除
SuperArrow( AnimatedVisibility(needMigration) {
title = "恢复旧版本配置", SectionSmallTitle("应用")
summary = "从旧版本的 SharedPreferences 恢复至 DataStore", }
onClick = { AnimatedVisibility(needMigration) {
scope.launch { Card {
val migration = PreferenceMigration(appContext) SuperArrow(
migration.migrate(clearSharedPrefs = false) title = "恢复旧版本配置",
snackHostState.showSnackbar("迁移完成,应用将重启") 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( val intent = context.packageManager.getLaunchIntentForPackage(
context.packageName context.packageName
)
intent?.apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
) )
} intent?.apply {
context.startActivity(intent) addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
)
}
context.startActivity(intent)
Process.killProcess(Process.myPid()) Process.killProcess(Process.myPid())
exitProcess(0) exitProcess(0)
} }
}, },
) )
}
} }
SectionSmallTitle("关于") SectionSmallTitle("关于")
@@ -313,7 +350,6 @@ fun SettingsScreen(
} }
} }
// TODO: 放进 [AppPageLazyColumn] 里
item { Spacer(Modifier.height(UiSpacing.BottomContent)) } 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.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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 io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Slider 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 import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable @Composable
fun SuperSlide( fun SuperSlider(
title: String, title: String,
summary: String, summary: String,
value: Float, value: Float,
@@ -82,60 +83,71 @@ fun SuperSlide(
}, },
) )
if (showInputDialog) { SliderInputDialog(
var valueText by remember(inputInitialValue) { mutableStateOf(inputInitialValue) } showDialog = showInputDialog,
val activeInputRange = inputValueRange ?: valueRange title = inputTitle,
SuperDialog( summary = inputHint,
show = true, initialValue = inputInitialValue,
onDismissRequest = { inputFilter = inputFilter,
showInputDialog = false inputValueRange = inputValueRange ?: valueRange,
holdArrow = false onDismissRequest = { showInputDialog = false },
}, onDismissFinished = { holdArrow = false },
) { onConfirm = { input ->
Row( onInputConfirm(input)
modifier = Modifier.fillMaxWidth(), showInputDialog = false
horizontalArrangement = Arrangement.Center, },
) { )
Text(text = inputTitle) }
}
@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( TextField(
value = valueText, modifier = Modifier.padding(bottom = 16.dp),
onValueChange = { valueText = inputFilter(it) }, value = text,
label = inputHint, maxLines = 1,
singleLine = true, onValueChange = { newValue ->
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), text = inputFilter(newValue)
modifier = Modifier },
.fillMaxWidth()
.padding(top = UiSpacing.Large),
) )
Row(
modifier = Modifier Row(horizontalArrangement = Arrangement.SpaceBetween) {
.fillMaxWidth()
.padding(top = UiSpacing.Large),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextButton( TextButton(
text = "取消", text = "取消",
onClick = onDismissRequest,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = {
showInputDialog = false
holdArrow = false
},
) )
Spacer(Modifier.width(20.dp))
TextButton( TextButton(
text = "确定", text = "确定",
modifier = Modifier.weight(1f),
onClick = { onClick = {
val inputValue = valueText.trim().toFloatOrNull() val inputValue = text.toFloatOrNull() ?: 0f
if (inputValue != null && inputValue >= activeInputRange.start && inputValue <= activeInputRange.endInclusive) { if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) {
onInputConfirm(valueText.trim()) onConfirm(text.trim())
showInputDialog = false
holdArrow = false
} }
}, },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(), 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 var maxSize: UShort = 0u, // to server
// --video-bit-rate // --video-bit-rate
var videoBitRate: UInt = 0u, // to server var videoBitRate: Int = 0, // to server
// --audio-bit-rate // --audio-bit-rate
var audioBitRate: UInt = 0u, // to server var audioBitRate: Int = 0, // to server
// --max-fps // --max-fps
var maxFps: String = "", // float to be parsed by the server var maxFps: String = "", // float to be parsed by the server
@@ -115,7 +115,8 @@ data class ClientOptions(
// var windowHeight: UShort, // var windowHeight: UShort,
// --display-id // --display-id
var displayId: UInt = 0u, // to server // -1 for empty text field
var displayId: Int = -1, // to server
// var videoBuffer: Tick, // var videoBuffer: Tick,
// var audioBuffer: Tick, // var audioBuffer: Tick,
@@ -278,7 +279,7 @@ data class ClientOptions(
} }
if (videoSource == VideoSource.CAMERA) { if (videoSource == VideoSource.CAMERA) {
if (displayId > 0u) { if (displayId > 0) {
throw IllegalArgumentException( throw IllegalArgumentException(
"--display-id is only available with --video-source=display" "--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( throw IllegalArgumentException(
"Cannot specify both --display-id and --new-display" "Cannot specify both --display-id and --new-display"
) )
} }
if (displayImePolicy != DisplayImePolicy.UNDEFINED if (displayImePolicy != DisplayImePolicy.UNDEFINED
&& displayId == 0u && newDisplay.isBlank() && displayId == 0 && newDisplay.isBlank()
) { ) {
throw IllegalArgumentException( throw IllegalArgumentException(
"--display-ime-policy is only supported on a secondary display" "--display-ime-policy is only supported on a secondary display"
@@ -466,7 +467,7 @@ data class ClientOptions(
} }
*/ */
if (control) { if (!control) {
if (turnScreenOff) { if (turnScreenOff) {
throw IllegalArgumentException( throw IllegalArgumentException(
"Cannot request to turn screen off if control is disabled" "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.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.view.KeyEvent
import androidx.core.net.toUri import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade 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.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer 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.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent 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.File
import java.io.InputStreamReader
import java.util.ArrayDeque
import kotlin.concurrent.thread
import kotlin.math.roundToInt
import kotlin.random.Random import kotlin.random.Random
import kotlin.random.nextUInt import kotlin.random.nextUInt
@@ -24,25 +38,25 @@ import kotlin.random.nextUInt
* - Audio playback * - Audio playback
* - Screen control * - Screen control
* *
* @param context Android context * @param appContext Android context
* @param serverAsset Asset path for the default server jar * @param serverAsset Asset path for the default server jar
* @param customServerUri Optional custom server URI (overrides serverAsset) * @param customServerUri Optional custom server URI (overrides serverAsset)
* @param serverVersion Server version string * @param serverVersion Server version string
* @param serverRemotePath Remote path where server jar will be pushed on device * @param serverRemotePath Remote path where server jar will be pushed on device
*/ */
class Scrcpy( class Scrcpy(
private val context: Context, private val appContext: Context,
private val adbService: NativeAdbService,
private val serverAsset: String = DEFAULT_SERVER_ASSET, private val serverAsset: String = DEFAULT_SERVER_ASSET,
private val customServerUri: String? = null, private val customServerUri: String? = null,
private val serverVersion: String = "3.3.4", private val serverVersion: String = "3.3.4",
private val serverRemotePath: String = DEFAULT_REMOTE_PATH, private val serverRemotePath: String = DEFAULT_REMOTE_PATH,
) { ) {
private val adbService = NativeAdbService(context) private val session = Session(adbService)
private val sessionManager = ScrcpySessionManager(adbService) private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext)
private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(context)
@Volatile @Volatile
private var currentSession: ScrcpySessionInfo? = null private var currentSession: Session.SessionInfo? = null
@Volatile @Volatile
private var isRunning: Boolean = false private var isRunning: Boolean = false
@@ -50,6 +64,19 @@ class Scrcpy(
@Volatile @Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null 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 { companion object {
private const val TAG = "Scrcpy" private const val TAG = "Scrcpy"
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
@@ -74,9 +101,7 @@ class Scrcpy(
} }
} }
suspend fun start( suspend fun start(options: ClientOptions): Session.SessionInfo = withContext(Dispatchers.IO) {
options: ClientOptions,
): ScrcpySessionInfo {
if (isRunning) { if (isRunning) {
throw IllegalStateException("Scrcpy session is already running") throw IllegalStateException("Scrcpy session is already running")
} }
@@ -109,25 +134,18 @@ class Scrcpy(
if (!options.control) { if (!options.control) {
Log.w(TAG, "start(): turnScreenOff ignored because control is disabled") Log.w(TAG, "start(): turnScreenOff ignored because control is disabled")
} else { } else {
runCatching { sessionManager.setDisplayPower(on = false) } runCatching { session.setDisplayPower(on = false) }
.onFailure { e -> Log.w(TAG, "start(): set display power failed", e) } .onFailure { e -> Log.w(TAG, "start(): set display power failed", e) }
} }
} }
// Create session info // Create session info
val session = ScrcpySessionInfo( currentSession = info
width = info.width,
height = info.height,
deviceName = info.deviceName,
codec = info.codecName,
controlEnabled = info.controlEnabled,
)
currentSession = session
isRunning = true isRunning = true
// Setup video consumer (notify NativeCoreFacade to setup decoders) // Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) { if (options.video) {
nativeCore.onScrcpySessionStarted(session, sessionManager) nativeCore.onScrcpySessionStarted(info, session)
} }
// Setup audio player // Setup audio player
@@ -142,7 +160,7 @@ class Scrcpy(
) )
val player = ScrcpyAudioPlayer(info.audioCodecId) val player = ScrcpyAudioPlayer(info.audioCodecId)
audioPlayer = player audioPlayer = player
sessionManager.attachAudioConsumer { packet -> session.attachAudioConsumer { packet ->
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig) player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
} }
} else { } else {
@@ -150,13 +168,13 @@ class Scrcpy(
} }
Log.i( Log.i(
TAG, "start(): Session started successfully - device=${session.deviceName}, " + TAG, "start(): Session started successfully - device=${info.deviceName}, " +
"video=${if (options.video) "${session.codec} ${session.width}x${session.height}" else "off"}, " + "video=${if (options.video) "${info.codecName} ${info.width}x${info.height}" else "off"}, " +
"audio=${if (options.audio) options.audioCodec.string else "off"}, " + "audio=${if (options.audio) options.audioCodec.string else "off"}, " +
"control=${options.control}" "control=${options.control}"
) )
return session return@withContext info
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "start(): Failed to start scrcpy session", e) 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) { if (!isRunning) {
Log.w(TAG, "stop(): No active session to stop") Log.w(TAG, "stop(): No active session to stop")
return false return@withContext false
} }
Log.i(TAG, "stop(): Stopping scrcpy session") Log.i(TAG, "stop(): Stopping scrcpy session")
return try { return@withContext try {
nativeCore.onScrcpySessionStopped() nativeCore.onScrcpySessionStopped()
sessionManager.clearVideoConsumer() session.clearVideoConsumer()
sessionManager.clearAudioConsumer() session.clearAudioConsumer()
sessionManager.stop() session.stop()
audioPlayer?.release() audioPlayer?.release()
audioPlayer = null audioPlayer = null
isRunning = false isRunning = false
@@ -196,11 +214,11 @@ class Scrcpy(
adbService.close() 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 { sealed class ListResult {
data class Encoders( data class Encoders(
@@ -217,13 +235,49 @@ class Scrcpy(
) : ListResult() ) : 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. * List various options from the scrcpy server.
* *
* @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.) * @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.)
* @return ListResult containing the requested information * @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()) { val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(serverAsset) extractAssetToCache(serverAsset)
} else { } else {
@@ -261,7 +315,7 @@ class Scrcpy(
val output = adbService.shell("$serverCommand 2>&1") val output = adbService.shell("$serverCommand 2>&1")
// Parse output based on list option // Parse output based on list option
return when (list) { return@withContext when (list) {
ListOptions.NULL -> { ListOptions.NULL -> {
throw IllegalArgumentException("Nothing to do with ListOptions.NULL") throw IllegalArgumentException("Nothing to do with ListOptions.NULL")
} }
@@ -377,7 +431,7 @@ class Scrcpy(
serverJar: File, serverJar: File,
options: ClientOptions, options: ClientOptions,
scid: UInt, scid: UInt,
): ScrcpySessionManager.SessionInfo { ): Session.SessionInfo {
adbService.push(serverJar.toPath(), serverRemotePath) adbService.push(serverJar.toPath(), serverRemotePath)
val serverParams = options.toServerParams(scid) val serverParams = options.toServerParams(scid)
@@ -394,18 +448,22 @@ class Scrcpy(
// Execute server (equivalent to sc_adb_execute in C) // Execute server (equivalent to sc_adb_execute in C)
Log.i(TAG, "executeServer(): Starting scrcpy server") Log.i(TAG, "executeServer(): Starting scrcpy server")
logEvent("scrcpy-server args: $serverCommand") logEvent("scrcpy-server args: $serverCommand")
return sessionManager.start( val sessionInfo = session.start(
serverJarPath = serverJar.toPath(),
serverCommand = serverCommand, serverCommand = serverCommand,
scid = scid, scid = scid,
options = options, 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 { private fun extractAssetToCache(assetPath: String): File {
val clean = assetPath.removePrefix("/") val clean = assetPath.removePrefix("/")
val source = context.assets.open(clean) val source = appContext.assets.open(clean)
val outputFile = File(context.cacheDir, File(clean).name) val outputFile = File(appContext.cacheDir, File(clean).name)
source.use { input -> source.use { input ->
outputFile.outputStream().use { output -> input.copyTo(output) } outputFile.outputStream().use { output -> input.copyTo(output) }
} }
@@ -414,11 +472,706 @@ class Scrcpy(
private fun extractUriToCache(uri: Uri): File { private fun extractUriToCache(uri: Uri): File {
val fileName = "custom-scrcpy-server.jar" val fileName = "custom-scrcpy-server.jar"
val outputFile = File(context.cacheDir, fileName) val outputFile = File(appContext.cacheDir, fileName)
context.contentResolver.openInputStream(uri).use { input -> appContext.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Unable to open selected server URI" } requireNotNull(input) { "Unable to open selected server URI" }
outputFile.outputStream().use { output -> input.copyTo(output) } outputFile.outputStream().use { output -> input.copyTo(output) }
} }
return outputFile 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 maxSize: UShort,
var videoBitRate: UInt, var videoBitRate: Int,
var audioBitRate: UInt, var audioBitRate: Int,
var maxFps: String, // float to be parsed by the server var maxFps: String, // float to be parsed by the server
var angle: 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 control: Boolean,
var displayId: UInt, var displayId: Int,
var newDisplay: String, var newDisplay: String,
var displayImePolicy: DisplayImePolicy, var displayImePolicy: DisplayImePolicy,
@@ -96,7 +96,7 @@ data class ServerParams(
const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n" const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n"
} }
private fun validate(str: String): Unit { private fun validate(str: String) {
// forbid special shell characters // forbid special shell characters
if (str.any { it in ILLEGAL_CHARACTER_SET }) { if (str.any { it in ILLEGAL_CHARACTER_SET }) {
throw IllegalArgumentException("Invalid server param: [$str]") throw IllegalArgumentException("Invalid server param: [$str]")
@@ -107,23 +107,28 @@ data class ServerParams(
return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR) return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR)
} }
fun toList(): MutableList<String> { fun toList(simplify: Boolean = false): MutableList<String> {
val cmd = mutableListOf<String>() val cmd = mutableListOf<String>()
cmd.add("scid=${scid.toString(16)}") if (!simplify) {
cmd.add("log_level=${logLevel.string}") cmd.add("scid=${scid.toString(16)}")
cmd.add("log_level=${logLevel.string}")
}
if (!video) { if (!video) {
cmd.add("video=false") cmd.add("video=false")
} }
if (videoBitRate > 0u) { if (videoBitRate > 0) {
cmd.add("video_bit_rate=$videoBitRate") cmd.add("video_bit_rate=$videoBitRate")
} }
if (!audio) { if (!audio) {
cmd.add("audio=false") cmd.add("audio=false")
} }
if (audioBitRate > 0u) { if (audioBitRate > 0) {
cmd.add("audio_bit_rate=$audioBitRate") if (audioCodec.isLossyAudio()!!) {
// 比官方实现多个判断编解码器类型
cmd.add("audio_bit_rate=$audioBitRate")
}
} }
if (videoCodec != Codec.H264) { if (videoCodec != Codec.H264) {
cmd.add("video_codec=${videoCodec.string}") cmd.add("video_codec=${videoCodec.string}")
@@ -177,7 +182,7 @@ data class ServerParams(
// By default, control is true // By default, control is true
cmd.add("control=false") cmd.add("control=false")
} }
if (displayId > 0u) { if (displayId >= 0) {
cmd.add("display_id=$displayId") cmd.add("display_id=$displayId")
} }
if (cameraId.isNotBlank()) { if (cameraId.isNotBlank()) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,8 @@ abstract class Settings(
operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty<T> = this 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 get(): T = getValue(pair)
suspend fun set(value: T) = setValue(pair, value) 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.annotation.SuppressLint
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.util.Log
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.TextureView import android.view.TextureView
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable 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.LinkOff
import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -48,9 +49,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput 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.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade 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.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut 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 io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -97,23 +98,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
import top.yukonga.miuix.kmp.utils.PressFeedbackType import top.yukonga.miuix.kmp.utils.PressFeedbackType
import kotlin.math.roundToInt 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 { private object UiMotionActions {
const val DOWN = 0 const val DOWN = 0
const val UP = 1 const val UP = 1
@@ -130,7 +114,7 @@ internal fun StatusCard(
statusLine: String, statusLine: String,
adbConnected: Boolean, adbConnected: Boolean,
streaming: Boolean, streaming: Boolean,
sessionInfo: ScrcpySessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
busyLabel: String?, busyLabel: String?,
connectedDeviceLabel: String, connectedDeviceLabel: String,
) { ) {
@@ -183,7 +167,7 @@ internal fun StatusCard(
), ),
secondSmall = StatusSmallCardSpec( secondSmall = StatusSmallCardSpec(
"编解码器", "编解码器",
sessionInfo.codec, sessionInfo.codecName,
), ),
) )
} }
@@ -270,7 +254,7 @@ internal fun PairingCard(
@Composable @Composable
internal fun PreviewCard( internal fun PreviewCard(
sessionInfo: ScrcpySessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
previewHeightDp: Int, previewHeightDp: Int,
controlsVisible: Boolean, controlsVisible: Boolean,
@@ -376,19 +360,51 @@ internal fun VirtualButtonCard(
internal fun ConfigPanel( internal fun ConfigPanel(
busy: Boolean, busy: Boolean,
audioForwardingSupported: Boolean, audioForwardingSupported: Boolean,
cameraMirroringSupported: Boolean,
onOpenAdvanced: () -> Unit, onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null, onStartStopHaptic: (() -> Unit)? = null,
onStart: () -> Unit, onStart: () -> Unit,
onStop: () -> Unit, onStop: () -> Unit,
sessionStarted: Boolean, sessionInfo: Scrcpy.Session.SessionInfo?,
onDisconnect: () -> Unit = {},
) { ) {
val scrcpyOptions = Storage.scrcpyOptions val scrcpyOptions = Storage.scrcpyOptions
val quickDevices = Storage.quickDevices
val context = LocalContext.current 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") SectionSmallTitle("Scrcpy")
Card { Card {
var audio by scrcpyOptions.audio.asMutableState()
SuperSwitch( SuperSwitch(
title = "音频转发", title = "音频转发",
summary = "转发设备音频到本机 (Android 11+)", summary = "转发设备音频到本机 (Android 11+)",
@@ -397,64 +413,46 @@ internal fun ConfigPanel(
enabled = !sessionStarted && audioForwardingSupported, 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( SuperDropdown(
title = "音频编码", title = "音频编码",
summary = "--audio-codec", summary = "--audio-codec",
items = audioCodecItems, items = audioCodecItems,
selectedIndex = audioCodecIndex, selectedIndex = audioCodecIndex,
onSelectedIndexChange = { audioCodec = AUDIO_CODEC_OPTIONS[it].first }, onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string },
enabled = !sessionStarted && audio, enabled = !sessionStarted && audio,
) )
if (audio && (audioCodec == "opus" || audioCodec == "aac")) { AnimatedVisibility(audio && (audioCodec == "opus" || audioCodec == "aac")) {
SuperSlide( SuperSlider(
title = "音频码率", title = "音频码率",
summary = "--audio-bit-rate", summary = "--audio-bit-rate",
value = audioBitRatePresetIndex.toFloat(), value = ScrcpyPresets.AudioBitRate.indexOfOrNearest(audioBitRate / 1000).toFloat(),
onValueChange = { value -> onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) 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(), valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
enabled = !sessionStarted, enabled = !sessionStarted,
unit = "Kbps", unit = "Kbps",
displayText = audioBitRate.toString(), displayText = (audioBitRate / 1000).toString(),
inputInitialValue = audioBitRate.toString(), inputInitialValue = (audioBitRate / 1000).toString(),
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 1f..Float.MAX_VALUE, inputValueRange = 1f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { raw -> 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( SuperDropdown(
title = "视频编码", title = "视频编码",
summary = "--video-codec", summary = "--video-codec",
items = videoCodecItems, items = videoCodecItems,
selectedIndex = videoCodecIndex, selectedIndex = videoCodecIndex,
onSelectedIndexChange = { videoCodec = VIDEO_CODEC_OPTIONS[it].first }, onSelectedIndexChange = { videoCodec = Codec.VIDEO[it].string },
enabled = !sessionStarted, enabled = !sessionStarted,
) )
SuperSlide( SuperSlider(
title = "视频码率", title = "视频码率",
summary = "--video-bit-rate", summary = "--video-bit-rate",
value = videoBitRateMbps, value = videoBitRateMbps,
@@ -481,7 +479,7 @@ internal fun ConfigPanel(
} }
} }
}, },
inputValueRange = 0.1f..Float.MAX_VALUE, inputValueRange = 0.1f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { raw -> onInputConfirm = { raw ->
raw.toFloatOrNull()?.let { parsed -> raw.toFloatOrNull()?.let { parsed ->
if (parsed >= 0.1f) { if (parsed >= 0.1f) {
@@ -498,23 +496,61 @@ internal fun ConfigPanel(
enabled = !sessionStarted, enabled = !sessionStarted,
) )
TextButton( Row(
text = if (sessionStarted) "停止" else "启动",
onClick = {
onStartStopHaptic?.invoke()
if (sessionStarted) onStop() else onStart()
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent) .padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent), .padding(bottom = UiSpacing.CardContent),
enabled = !busy, horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
colors = if (sessionStarted) { ) {
ButtonDefaults.textButtonColors() AnimatedVisibility(!isQuickConnected) {
} else { AnimatedVisibility(!sessionStarted) {
ButtonDefaults.textButtonColorsPrimary() 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 = "使用配对码配对设备", title = "使用配对码配对设备",
summary = "使用六位数的配对码配对新设备", summary = "使用六位数的配对码配对新设备",
onDismissRequest = { onDismissRequest = {
clearInputs()
onDismissRequest() onDismissRequest()
}, },
onDismissFinished = onDismissFinished, onDismissFinished = {
clearInputs()
onDismissFinished()
},
content = { content = {
TextField( TextField(
value = host, value = host,
@@ -634,7 +672,6 @@ private fun PairingDialog(
TextButton( TextButton(
text = "取消", text = "取消",
onClick = { onClick = {
clearInputs()
onDismissRequest() onDismissRequest()
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -643,7 +680,7 @@ private fun PairingDialog(
text = "配对", text = "配对",
onClick = { onClick = {
onConfirm(host.trim(), port.trim(), code.trim()) onConfirm(host.trim(), port.trim(), code.trim())
clearInputs() onDismissRequest()
}, },
enabled = enabled && enabled = enabled &&
host.trim().isNotBlank() && 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") @SuppressLint("DefaultLocale")
private fun formatBitRate(value: Float): String = String.format("%.1f", value) private fun formatBitRate(value: Float): String = String.format("%.1f", value)
@@ -690,7 +718,7 @@ internal fun LogsPanel(lines: List<String>) {
*/ */
class TouchEventHandler( class TouchEventHandler(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val session: ScrcpySessionInfo, private val session: Scrcpy.Session.SessionInfo,
private val touchAreaSize: IntSize, private val touchAreaSize: IntSize,
private val activePointerIds: LinkedHashSet<Int>, private val activePointerIds: LinkedHashSet<Int>,
private val activePointerPositions: LinkedHashMap<Int, Offset>, private val activePointerPositions: LinkedHashMap<Int, Offset>,
@@ -814,7 +842,11 @@ class TouchEventHandler(
val pos = activePointerPositions[pointerId] ?: Offset.Zero val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y, bounds) val (x, y) = mapToDevice(pos.x, pos.y, bounds)
coroutineScope.launch { 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 activePointerIds -= pointerId
activePointerPositions.remove(pointerId) activePointerPositions.remove(pointerId)
@@ -877,7 +909,15 @@ class TouchEventHandler(
activePointerDevicePositions[pointerId] = x to y activePointerDevicePositions[pointerId] = x to y
justPressedPointerIds += pointerId justPressedPointerIds += pointerId
coroutineScope.launch { 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) val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerDevicePositions[pointerId] = x to y activePointerDevicePositions[pointerId] = x to y
coroutineScope.launch { 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 @Composable
fun FullscreenControlScreen( fun FullscreenControlScreen(
session: ScrcpySessionInfo, session: Scrcpy.Session.SessionInfo,
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
onDismiss: () -> Unit, onDismiss: () -> Unit,
showDebugInfo: Boolean, showDebugInfo: Boolean,
@@ -1071,30 +1119,16 @@ fun FullscreenControlScreen(
private fun ScrcpyVideoSurface( private fun ScrcpyVideoSurface(
modifier: Modifier, modifier: Modifier,
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
session: ScrcpySessionInfo?, session: Scrcpy.Session.SessionInfo?,
) { ) {
val surfaceTag = "video-main" val surfaceTag = "video-main"
var currentSurface by remember { mutableStateOf<Surface?>(null) } var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(session, currentSurface) { LaunchedEffect(session, currentSurface) {
if (session != null && currentSurface != null) { val surface = currentSurface
nativeCore.registerVideoSurface(surfaceTag, currentSurface!!) if (session != null && surface != null && surface.isValid) {
} nativeCore.registerVideoSurface(surfaceTag, surface)
// 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
} }
} }
@@ -1108,9 +1142,15 @@ private fun ScrcpyVideoSurface(
width: Int, width: Int,
height: Int height: Int
) { ) {
currentSurface?.release() // Release stale surface if any
@SuppressLint("Recycle") @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( override fun onSurfaceTextureSizeChanged(
@@ -1120,15 +1160,9 @@ private fun ScrcpyVideoSurface(
) = Unit ) = Unit
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
val released = currentSurface // Return false to keep the SurfaceTexture alive
currentSurface = null // This prevents the surface from being destroyed when the view is detached
if (released != null) { return false
scope.launch {
nativeCore.unregisterVideoSurface(surfaceTag, released)
}
released.release()
}
return true
} }
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit
@@ -1222,15 +1256,13 @@ internal fun DeviceTile(
internal fun QuickConnectCard( internal fun QuickConnectCard(
input: String, input: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onFocusChange: (() -> Unit)? = null,
onConnect: () -> Unit, onConnect: () -> Unit,
onAddDevice: () -> Unit, onAddDevice: () -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var tempText by remember(input) { mutableStateOf(input) }
var tempFocusState by remember(input) { mutableStateOf(false) }
Card( Card(
colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer),
@@ -1259,9 +1291,9 @@ internal fun QuickConnectCard(
color = MiuixTheme.colorScheme.onPrimaryContainer, color = MiuixTheme.colorScheme.onPrimaryContainer,
) )
} }
TextField( SuperTextField(
value = tempText, value = input,
onValueChange = { tempText = it }, onValueChange = onValueChange,
label = "IP:PORT", label = "IP:PORT",
enabled = enabled, enabled = enabled,
useLabelAsPlaceholder = true, useLabelAsPlaceholder = true,
@@ -1269,17 +1301,10 @@ internal fun QuickConnectCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent) .padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.SectionTitleLeadingGap) .padding(bottom = UiSpacing.SectionTitleLeadingGap),
.onFocusChanged { focusState ->
// 失去焦点时回调
if (!focusState.isFocused && tempFocusState) {
onValueChange(tempText)
}
tempFocusState = focusState.isFocused
}
.focusRequester(focusRequester),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onFocusLost = onFocusChange,
) )
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -11,6 +11,7 @@ androidxJunit = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
miuix = "0.8.7" miuix = "0.8.7"
material = "1.13.0" material = "1.13.0"
runtime = "1.10.5"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -32,6 +33,7 @@ material = { group = "com.google.android.material", name = "material", version.r
miuix = { group = "top.yukonga.miuix.kmp", name = "miuix", version.ref = "miuix" } miuix = { group = "top.yukonga.miuix.kmp", name = "miuix", version.ref = "miuix" }
miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.ref = "miuix" } miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.ref = "miuix" }
miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" } miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }