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:
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 -> ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user