feat: picture in picture

- 移除了 MainScreen 中的全屏方向处理及相关状态管理
- 引入 AppRuntime 用于管理跨 Activity 的共享实例
- 创建 StreamScreen 用于画中画模式
- 实现 VideoOutputTarget 和 VideoOutputTargetState 以管理视频输出状态
This commit is contained in:
Miuzarte
2026-04-11 15:26:03 +08:00
parent 98443040e6
commit dbf83b33c2
24 changed files with 823 additions and 435 deletions

View File

@@ -25,6 +25,7 @@
## 已知问题
- 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止
- 虚拟按键的截图实现方式为发送
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
@@ -60,6 +61,7 @@ specific abi:
- JNI ADB 实现: [rikkaapps/shizuku](https://github.com/rikkaapps/shizuku), [vvb2060/ndk.boringssl](https://github.com/vvb2060), [lsposed/libcxx](https://github.com/lsposed/libcxx)
- 界面组件: [YuKongA/miuix](https://github.com/compose-miuix-ui/miuix)
- 界面设计参考: [tiann/KernelSU/manager](https://github.com/tiann/KernelSU/tree/main/manager)
- 画中画实现参考: [ClassicOldSong/moonlight-android](https://github.com/ClassicOldSong/moonlight-android)
## License

View File

@@ -8,8 +8,6 @@
## FEATURES
- 设置项连接设备后马上启用scrcpy会话
### LOWER PRIORITY
顺序无关
@@ -17,5 +15,4 @@
- 多配置切换
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
- 横屏布局
- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX)
- I18N

View File

@@ -20,8 +20,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26
targetSdk = 36
versionCode = 6
versionName = "0.1.0"
versionCode = 7
versionName = "0.1.1"
externalNativeBuild {
cmake {
@@ -57,8 +57,8 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
@@ -80,6 +80,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.pip)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)

View File

@@ -19,6 +19,10 @@
android:supportsRtl="true"
android:theme="@style/Theme.ScrcpyForAndroid">
<receiver
android:name=".services.PictureInPictureActionReceiver"
android:exported="false" />
<activity
android:name=".MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
@@ -30,6 +34,13 @@
</intent-filter>
</activity>
<activity
android:name=".StreamActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
android:exported="false"
android:resizeableActivity="true"
android:supportsPictureInPicture="true" />
<!-- <profileable android:shell="true" /> -->
</application>

View File

@@ -1,20 +1,25 @@
package io.github.miuzarte.scrcpyforandroid
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// initialize settings singleton
Storage.init(applicationContext)
AppRuntime.init(applicationContext)
val migration = PreferenceMigration(applicationContext)
runBlocking {

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -21,11 +20,7 @@ import java.util.concurrent.CopyOnWriteArraySet
* - Surface/Decoder management for video rendering
* - Video size and FPS monitoring
*/
class NativeCoreFacade private constructor() {
@Volatile
var session: Scrcpy.Session? = null
private set
object NativeCoreFacade {
private val sessionLifecycleMutex = Mutex()
private val renderer = PersistentVideoRenderer()
private var activeSurfaceId: Int? = null
@@ -118,25 +113,10 @@ class NativeCoreFacade private constructor() {
}
}
fun addVideoSizeListener(listener: (Int, Int) -> Unit) {
videoSizeListeners.add(listener)
}
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) {
videoSizeListeners.remove(listener)
}
fun addVideoFpsListener(listener: (Float) -> Unit) {
videoFpsListeners.add(listener)
}
fun removeVideoFpsListener(listener: (Float) -> Unit) {
videoFpsListeners.remove(listener)
}
suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) {
session?.pressBackOrTurnScreenOn(action)
}
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener)
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.remove(listener)
fun addVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.add(listener)
fun removeVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.remove(listener)
/**
* Called by Scrcpy.kt when a session starts.
@@ -146,7 +126,6 @@ class NativeCoreFacade private constructor() {
session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session
) = sessionLifecycleMutex.withLock {
this.session = sessionMgr
currentSessionInfo = session
releaseAllDecoders()
synchronized(bootstrapLock) {
@@ -185,7 +164,6 @@ class NativeCoreFacade private constructor() {
* Cleans up decoders and resets state.
*/
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
session = null
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
@@ -194,21 +172,8 @@ class NativeCoreFacade private constructor() {
currentSessionInfo = null
}
companion object {
private const val TAG = "NativeCoreFacade"
private const val MAX_BOOTSTRAP_PACKETS = 90
@Volatile
private var instance: NativeCoreFacade? = null
// TODO ???
fun get(context: Context): NativeCoreFacade {
return instance ?: synchronized(this) {
instance ?: NativeCoreFacade().also { instance = it }
}
}
}
private const val TAG = "NativeCoreFacade"
private const val MAX_BOOTSTRAP_PACKETS = 90
private data class CachedPacket(
val data: ByteArray,

View File

@@ -0,0 +1,160 @@
package io.github.miuzarte.scrcpyforandroid
import android.app.PendingIntent
import android.app.RemoteAction
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.drawable.Icon
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.app.PictureInPictureParamsCompat
import androidx.core.pip.BasicPictureInPicture
import androidx.core.pip.PictureInPictureDelegate
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class StreamActivity : ComponentActivity() {
private lateinit var basicPip: BasicPictureInPicture // = this
// pip 是否已被配置、允许进入
private var pipConfigured: Boolean = false
private var pipParams = PictureInPictureParamsCompat
.Builder()
.setEnabled(false)
.build()
// 是否处于 PiP。
// 这台设备上进入 PiP 时,动画事件比 onPictureInPictureModeChanged() 更稳定,
// 所以进入 PiP 直接由动画事件置为 true。
private val _pipModeState = MutableStateFlow(false)
val pipModeState: StateFlow<Boolean> = _pipModeState
val pipStopAction: RemoteAction by lazy {
val intent = Intent(this, PictureInPictureActionReceiver::class.java).apply {
action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY
`package` = packageName
}
val pendingIntent = PendingIntent.getBroadcast(
this,
1,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
RemoteAction(
Icon.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel),
"停止投屏",
"停止投屏",
pendingIntent,
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
basicPip = BasicPictureInPicture(this)
basicPip.addOnPictureInPictureEventListener(
executor = mainExecutor,
listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener {
override fun onPictureInPictureEvent(
event: PictureInPictureDelegate.Event,
newConfig: Configuration?,
) {
// HyperOS 3 下稳定收到的是进入动画事件
// 这里直接把进入动画开始视为已经进入 PiP尽量少依赖平台回调
when (event) {
PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> {
_pipModeState.value = true
VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE)
}
PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {
_pipModeState.value = true
}
PictureInPictureDelegate.Event.STASHED -> {}
PictureInPictureDelegate.Event.UNSTASHED -> {}
// 收不到
// PictureInPictureDelegate.Event.ENTERED -> {}
// PictureInPictureDelegate.Event.EXITED -> {}
}
}
}
)
setContent {
StreamScreen(activity = this)
}
}
fun configurePictureInPicture(
enabled: Boolean,
params: PictureInPictureParamsCompat,
) {
// 由 StreamScreen 决定何时更新 PiP 参数;
// StreamActivity 这里只负责缓存并转发最新配置
pipConfigured = enabled
pipParams = params
basicPip
.setEnabled(enabled)
.setPictureInPictureParams(params)
}
fun enterConfiguredPip(): Boolean {
// onUserLeaveHint() 可能发生在 PiP 还没准备好之前,
// 所以这里先做一次兜底判断
if (!pipConfigured) return false
return runCatching {
enterPictureInPictureMode(pipParams)
true
}.getOrElse {
VideoOutputTargetState.set(VideoOutputTarget.FULLSCREEN)
false
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
enterConfiguredPip()
}
override fun onDestroy() {
super.onDestroy()
// 回到全屏也会停止
/*
if (_pipModeState.value) {
Thread {
runBlocking {
AppRuntime.scrcpy?.stop()
}
}.start()
}
*/
}
// MIUI 不进
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (!isInPictureInPictureMode) {
_pipModeState.value = false
VideoOutputTargetState.set(VideoOutputTarget.FULLSCREEN)
}
}
companion object {
fun createIntent(context: Context): Intent {
return Intent(context, StreamActivity::class.java)
}
}
}

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
@@ -23,9 +22,14 @@ import java.util.concurrent.atomic.AtomicReference
* Uses Android's `NsdManager` to resolve services and returns a host:port pair
* when a suitable service is found within the provided timeout.
*/
internal class AdbMdnsDiscoverer(context: Context) {
internal object AdbMdnsDiscoverer {
private val nsdManager = context.getSystemService(NsdManager::class.java)
private lateinit var nsdManager: NsdManager
fun init(context: android.content.Context) {
if (::nsdManager.isInitialized) return
nsdManager = context.applicationContext.getSystemService(NsdManager::class.java)
}
/**
* Discover a device that advertises the ADB connect service via mDNS.
@@ -46,6 +50,7 @@ internal class AdbMdnsDiscoverer(context: Context) {
timeoutMs: Long,
includeLanDevices: Boolean,
): Pair<String, Int>? {
check(::nsdManager.isInitialized) { "AdbMdnsDiscoverer is not initialized" }
val resultPort = AtomicInteger(-1)
val resultHost = AtomicReference<String?>(null)
val discoveryFinished = AtomicBoolean(false)
@@ -131,9 +136,7 @@ internal class AdbMdnsDiscoverer(context: Context) {
true
}
companion object {
private const val TAG = "AdbMdnsDiscoverer"
private const val TLS_CONNECT = "_adb-tls-connect._tcp"
private const val TLS_PAIRING = "_adb-tls-pairing._tcp"
}
private const val TAG = "AdbMdnsDiscoverer"
private const val TLS_CONNECT = "_adb-tls-connect._tcp"
private const val TLS_PAIRING = "_adb-tls-pairing._tcp"
}

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.os.Build
import android.util.Base64
import android.util.Log
@@ -43,7 +42,7 @@ import kotlin.concurrent.thread
* This type is responsible for persisting the private key and performing
* pairing/connect discovery helpers.
*/
internal class DirectAdbTransport(private val context: Context) {
internal object DirectAdbTransport {
private val keys: Pair<PrivateKey, ByteArray> by lazy { runBlocking { loadOrCreate() } }
@@ -87,19 +86,17 @@ internal class DirectAdbTransport(private val context: Context) {
fun discoverPairingService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true
): Pair<String, Int>? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
return AdbMdnsDiscoverer(context).discoverPairingService(timeoutMs, includeLanDevices)
}
includeLanDevices: Boolean = true,
): Pair<String, Int>? =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) null
else AdbMdnsDiscoverer.discoverPairingService(timeoutMs, includeLanDevices)
fun discoverConnectService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true
): Pair<String, Int>? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
return AdbMdnsDiscoverer(context).discoverConnectService(timeoutMs, includeLanDevices)
}
includeLanDevices: Boolean = true,
): Pair<String, Int>? =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) null
else AdbMdnsDiscoverer.discoverConnectService(timeoutMs, includeLanDevices)
/**
* Load persisted RSA keypair from DataStore, or generate a new one.
@@ -200,9 +197,7 @@ internal class DirectAdbTransport(private val context: Context) {
return buf.array()
}
companion object {
private const val TAG = "DirectAdbTransport"
}
private const val TAG = "DirectAdbTransport"
}
/**
@@ -336,7 +331,9 @@ internal class DirectAdbConnection(
*/
fun openStream(service: String): AdbSocketStream {
val localId = nextLocalId.getAndIncrement()
val stream = AdbSocketStream(localId) { cmd, a0, a1, d -> sendMsg(cmd, a0, a1, d) }
val stream = AdbSocketStream(localId) { command, arg0, arg1, data ->
sendMsg(command, arg0, arg1, data)
}
streams[localId] = stream
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
try {
@@ -349,7 +346,8 @@ internal class DirectAdbConnection(
}
fun shell(command: String): String =
openStream("shell:$command").use { it.inputStream.readBytes().toString(Charsets.UTF_8) }
openStream("shell:$command")
.use { it.inputStream.readBytes().toString(Charsets.UTF_8) }
/**
* Push raw bytes to a remote path using the minimal ADB "sync" protocol.
@@ -357,40 +355,41 @@ internal class DirectAdbConnection(
* - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure.
*/
fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) {
openStream("sync:").use { stream ->
val out = stream.outputStream
val inp = stream.inputStream
val pathMode = "$remotePath,$unixMode".toByteArray(Charsets.UTF_8)
openStream("sync:")
.use { stream ->
val out = stream.outputStream
val inp = stream.inputStream
val pathMode = "$remotePath,$unixMode".toByteArray(Charsets.UTF_8)
out.write("SEND".toByteArray(Charsets.US_ASCII))
out.writeIntLE(pathMode.size)
out.write(pathMode)
out.write("SEND".toByteArray(Charsets.US_ASCII))
out.writeIntLE(pathMode.size)
out.write(pathMode)
val chunkBuf = ByteArray(64 * 1024)
var offset = 0
while (offset < data.size) {
val len = minOf(chunkBuf.size, data.size - offset)
out.write("DATA".toByteArray(Charsets.US_ASCII))
out.writeIntLE(len)
out.write(data, offset, len)
offset += len
val chunkBuf = ByteArray(64 * 1024)
var offset = 0
while (offset < data.size) {
val len = minOf(chunkBuf.size, data.size - offset)
out.write("DATA".toByteArray(Charsets.US_ASCII))
out.writeIntLE(len)
out.write(data, offset, len)
offset += len
}
out.write("DONE".toByteArray(Charsets.US_ASCII))
out.writeIntLE((System.currentTimeMillis() / 1000).toInt())
out.flush()
val idBuf = ByteArray(4).also { inp.readExact(it) }
val msgLen = inp.readIntLE()
val id = String(idBuf, Charsets.US_ASCII)
if (id != "OKAY") {
val msg = if (msgLen > 0) ByteArray(msgLen).also { inp.readExact(it) }
.toString(Charsets.UTF_8) else id
throw IOException("ADB push failed: $msg")
} else if (msgLen > 0) {
inp.skip(msgLen.toLong())
}
}
out.write("DONE".toByteArray(Charsets.US_ASCII))
out.writeIntLE((System.currentTimeMillis() / 1000).toInt())
out.flush()
val idBuf = ByteArray(4).also { inp.readExact(it) }
val msgLen = inp.readIntLE()
val id = String(idBuf, Charsets.US_ASCII)
if (id != "OKAY") {
val msg = if (msgLen > 0) ByteArray(msgLen).also { inp.readExact(it) }
.toString(Charsets.UTF_8) else id
throw IOException("ADB push failed: $msg")
} else if (msgLen > 0) {
inp.skip(msgLen.toLong())
}
}
}
fun isAlive(): Boolean {
@@ -568,7 +567,7 @@ internal class DirectAdbConnection(
*/
class AdbSocketStream(
val localId: Int,
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
private val sender: (cmd: Int, arg0: Int, arg1: Int, `data`: ByteArray) -> Unit,
) : Closeable {
companion object {
@@ -619,12 +618,13 @@ class AdbSocketStream(
}
override fun close() {
if (!closed) {
closed = true
val r = remoteId
if (r != 0) runCatching { sender(A_CLSE, localId, r, ByteArray(0)) }
queue.offer(EndOfStreamMarker)
if (closed) return
closed = true
if (remoteId != 0) runCatching {
sender(A_CLSE, localId, remoteId, ByteArray(0))
}
queue.offer(EndOfStreamMarker)
}
private inner class InStream : InputStream() {

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
@@ -19,8 +18,8 @@ import kotlin.time.Duration
*
* All network operations are executed on Dispatchers.IO.
*/
class NativeAdbService(appContext: Context) {
private val transport = DirectAdbTransport(appContext)
object NativeAdbService {
private val transport = DirectAdbTransport
private val mutex = Mutex()
@Volatile
@@ -55,7 +54,7 @@ class NativeAdbService(appContext: Context) {
suspend fun discoverPairingService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true
includeLanDevices: Boolean = true,
): Pair<String, Int>? = mutex.withLock {
return@withLock try {
transport.discoverPairingService(timeoutMs, includeLanDevices)
@@ -67,7 +66,7 @@ class NativeAdbService(appContext: Context) {
suspend fun discoverConnectService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true
includeLanDevices: Boolean = true,
): Pair<String, Int>? = mutex.withLock {
return@withLock try {
transport.discoverConnectService(timeoutMs, includeLanDevices)
@@ -162,7 +161,5 @@ class NativeAdbService(appContext: Context) {
?: throw IllegalStateException("ADB not connected")
}
companion object {
private const val TAG = "NativeAdbService"
}
private const val TAG = "NativeAdbService"
}

View File

@@ -54,7 +54,7 @@ class PersistentVideoRenderer {
private var stMatrixHandle = 0
private var samplerHandle = 0
private val initLock = Object()
private val initLock = Any()
fun getDecoderSurface(): Surface {
ensureInitialized()

View File

@@ -2,7 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
import android.app.Activity
import android.content.pm.ActivityInfo
import android.util.Log
import android.view.WindowManager
import androidx.compose.foundation.layout.PaddingValues
@@ -26,7 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.StreamActivity
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
@@ -36,6 +35,7 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
@@ -88,10 +88,8 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
@Composable
fun DeviceTabScreen(
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
scrcpy: Scrcpy,
scrollBehavior: ScrollBehavior,
scrcpy: Scrcpy,
onOpenReorderDevices: () -> Unit,
) {
val navigator = LocalRootNavigator.current
@@ -135,10 +133,8 @@ fun DeviceTabScreen(
) { pagePadding ->
DeviceTabPage(
contentPadding = pagePadding,
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
scrollBehavior = scrollBehavior,
scrcpy = scrcpy,
)
}
}
@@ -146,10 +142,8 @@ fun DeviceTabScreen(
@Composable
fun DeviceTabPage(
contentPadding: PaddingValues,
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
scrcpy: Scrcpy,
scrollBehavior: ScrollBehavior,
scrcpy: Scrcpy,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -158,7 +152,6 @@ fun DeviceTabPage(
val haptics = LocalAppHaptics.current
val navigator = LocalRootNavigator.current
val fullscreenNavigationState = LocalFullscreenNavigationState.current
val snackbar = LocalSnackbarController.current
val asBundleShared by appSettings.bundleState.collectAsState()
@@ -281,11 +274,11 @@ fun DeviceTabPage(
* Disconnect the current ADB connection and stop any running scrcpy session.
*
* Concurrency / thread boundary:
* - Native calls that may block are executed on ADB dispatcher using adbService.withAdbDispatcher.
* - Native calls that may block are executed on ADB dispatcher.
* - This ensures UI coroutines are never blocked by synchronous native I/O.
*
* Side effects:
* - Calls `scrcpy.stop()` and `adbService.disconnect()` (best-effort).
* - Calls `scrcpy.stop()` and `NativeAdbService.disconnect()` (best-effort).
* - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`,
* `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`.
* - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided.
@@ -307,7 +300,7 @@ fun DeviceTabPage(
// Also stops scrcpy.
runCatching { scrcpy.stop() }
setKeepScreenOn(false)
runCatching { adbService.disconnect() }
runCatching { NativeAdbService.disconnect() }
adbConnected = false
currentTargetHost = ""
currentTargetPort = Defaults.ADB_PORT
@@ -376,7 +369,7 @@ fun DeviceTabPage(
*/
suspend fun connectWithTimeout(host: String, port: Int) {
return withTimeout(ADB_CONNECT_TIMEOUT_MS) {
adbService.connect(host, port)
NativeAdbService.connect(host, port)
}
}
@@ -395,7 +388,7 @@ fun DeviceTabPage(
*/
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
val connected = adbService.isConnected()
val connected = NativeAdbService.isConnected()
if (!connected) {
return@withTimeout false
}
@@ -564,7 +557,7 @@ fun DeviceTabPage(
currentTargetHost = host
currentTargetPort = port
val info = fetchConnectedDeviceInfo(adbService, host, port)
val info = fetchConnectedDeviceInfo(NativeAdbService, host, port)
val fullLabel = if (info.serial.isNotBlank()) {
"${info.model} (${info.serial})"
} else {
@@ -646,7 +639,7 @@ fun DeviceTabPage(
}
val discovered = withContext(Dispatchers.IO) {
adbService.discoverConnectService(
NativeAdbService.discoverConnectService(
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
includeLanDevices = asBundle.adbMdnsLanDiscovery,
)
@@ -705,8 +698,8 @@ fun DeviceTabPage(
fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") {
nativeCore.session?.injectKeycode(0, keycode)
nativeCore.session?.injectKeycode(1, keycode)
scrcpy.injectKeycode(0, keycode)
scrcpy.injectKeycode(1, keycode)
}
}
@@ -870,7 +863,7 @@ fun DeviceTabPage(
busy = busy,
autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = {
adbService.discoverPairingService(
NativeAdbService.discoverPairingService(
includeLanDevices = asBundle.adbMdnsLanDiscovery,
)
},
@@ -879,7 +872,7 @@ fun DeviceTabPage(
val resolvedHost = host.trim()
val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim()
val ok = adbService.pair(
val ok = NativeAdbService.pair(
resolvedHost,
resolvedPort,
resolvedCode,
@@ -948,24 +941,13 @@ fun DeviceTabPage(
item {
PreviewCard(
sessionInfo = sessionInfo,
nativeCore = nativeCore,
previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120),
controlsVisible = previewControlsVisible,
onTapped = {
previewControlsVisible = !previewControlsVisible
},
onOpenFullscreen = {
val currentSession = sessionInfo
if (currentSession != null) {
fullscreenNavigationState.setOrientation(
if (currentSession.width >= currentSession.height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
)
navigator.push(RootScreen.Fullscreen)
}
context.startActivity(StreamActivity.createIntent(context))
},
)
}

View File

@@ -1,11 +1,20 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
import android.app.Activity
import android.graphics.Rect
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -13,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -21,16 +31,27 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import kotlinx.coroutines.CoroutineScope
@@ -40,20 +61,20 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
@Composable
fun FullscreenControlScreen(
scrcpy: Scrcpy,
nativeCore: NativeCoreFacade,
onBack: () -> Unit,
isInPip: Boolean,
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
onVideoBoundsInWindowChanged: (Rect?) -> Unit,
) {
val navigator = LocalRootNavigator.current
BackHandler(enabled = true, onBack = navigator.pop)
BackHandler(enabled = true, onBack = onBack)
val context = LocalContext.current
val haptics = LocalAppHaptics.current
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity }
@@ -128,21 +149,21 @@ fun FullscreenControlScreen(
onVideoSizeChanged(session.width, session.height)
}
DisposableEffect(nativeCore) {
DisposableEffect(Unit) {
val listener: (Float) -> Unit = { fps ->
currentFps = fps
}
nativeCore.addVideoFpsListener(listener)
NativeCoreFacade.addVideoFpsListener(listener)
onDispose {
nativeCore.removeVideoFpsListener(listener)
NativeCoreFacade.removeVideoFpsListener(listener)
}
}
suspend fun sendKeycode(keycode: Int) {
runCatching {
withContext(Dispatchers.IO) {
nativeCore.session?.injectKeycode(0, keycode)
nativeCore.session?.injectKeycode(1, keycode)
scrcpy.injectKeycode(0, keycode)
scrcpy.injectKeycode(1, keycode)
}
}.onFailure { e ->
Log.w(
@@ -160,16 +181,17 @@ fun FullscreenControlScreen(
.padding(contentPadding),
) {
val session = currentSession ?: return@Box
FullscreenControlScreen(
FullscreenControlPage(
session = session,
nativeCore = nativeCore,
onDismiss = navigator.pop,
showDebugInfo = fullscreenDebugInfo,
onDismiss = onBack,
showDebugInfo = fullscreenDebugInfo && !isInPip,
currentFps = currentFps,
enableBackHandler = false,
interactive = !isInPip,
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
withContext(Dispatchers.IO) {
nativeCore.session?.injectTouch(
scrcpy.injectTouch(
action = action,
pointerId = pointerId,
x = x,
@@ -184,28 +206,174 @@ fun FullscreenControlScreen(
},
)
if (showFullscreenVirtualButtons) {
if (showFullscreenVirtualButtons && !isInPip) {
bar.Fullscreen(
modifier = Modifier.align(Alignment.BottomCenter),
onAction = { action ->
action.keycode?.let {
sendKeycode(it)
}
action.keycode?.let { sendKeycode(it) }
},
)
}
if (showFullscreenFloatingButton) {
if (showFullscreenFloatingButton && !isInPip) {
bar.FloatingBall(
actions = floatingActions,
modifier = Modifier.fillMaxSize(),
onAction = { action ->
action.keycode?.let {
sendKeycode(it)
}
action.keycode?.let { sendKeycode(it) }
},
)
}
}
}
}
/**
* FullscreenControlScreen
*
* Purpose:
* - Presents a fullscreen interactive touch surface that maps Compose touch events
* to device coordinates and injects them via [onInjectTouch].
* - Responsible for pointer tracking, multi-touch mapping, coordinate normalization,
* and lifetime of synthetic touch events sent to the device.
*
* Concurrency and side-effects:
* - All heavy computations are local to the UI thread; injection itself is a quick
* callback (`onInjectTouch`) which delegates to native code elsewhere — keep that
* callback lightweight.
* - Use `pointerInteropFilter` to receive raw MotionEvent instances for precise
* multi-touch handling and to map Android pointer IDs to device pointers.
*/
@Composable
fun FullscreenControlPage(
session: Scrcpy.Session.SessionInfo,
onDismiss: () -> Unit,
showDebugInfo: Boolean,
currentFps: Float,
enableBackHandler: Boolean = true,
interactive: Boolean = true,
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
val coroutineScope = rememberCoroutineScope()
var touchAreaSize by remember { mutableStateOf(IntSize.Zero) }
val activePointerIds = remember { linkedSetOf<Int>() }
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
val activePointerDevicePositions = remember { linkedMapOf<Int, Pair<Int, Int>>() }
val pointerLabels = remember { linkedMapOf<Int, Int>() }
var nextPointerLabel by remember { mutableIntStateOf(1) }
var activeTouchCount by remember { mutableIntStateOf(0) }
var activeTouchDebug by remember { mutableStateOf("") }
val touchEventHandler = remember(session, touchAreaSize) {
TouchEventHandler(
coroutineScope = coroutineScope,
session = session,
touchAreaSize = touchAreaSize,
activePointerIds = activePointerIds,
activePointerPositions = activePointerPositions,
activePointerDevicePositions = activePointerDevicePositions,
pointerLabels = pointerLabels,
nextPointerLabel = nextPointerLabel,
onInjectTouch = onInjectTouch,
onActiveTouchCountChanged = { activeTouchCount = it },
onActiveTouchDebugChanged = { activeTouchDebug = it },
onNextPointerLabelChanged = { nextPointerLabel = it },
)
}
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.then(
if (interactive)
Modifier.pointerInteropFilter { event ->
touchEventHandler.handleMotionEvent(event)
}
else Modifier
)
.onSizeChanged { touchAreaSize = it },
) {
val sessionAspect =
if (session.height == 0) 16f / 9f
else session.width.toFloat() / session.height.toFloat()
Box(
modifier = Modifier
.align(Alignment.Center)
.then(
if (sessionAspect > (maxWidth.value / maxHeight.value))
Modifier
.fillMaxWidth()
.aspectRatio(sessionAspect)
else
Modifier
.fillMaxHeight()
.aspectRatio(sessionAspect)
),
) {
ScrcpyVideoSurface(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
onVideoBoundsInWindowChanged(
Rect(
bounds.left.toInt(),
bounds.top.toInt(),
bounds.right.toInt(),
bounds.bottom.toInt(),
)
)
},
session = session,
target = if (interactive) VideoOutputTarget.FULLSCREEN else VideoOutputTarget.PICTURE_IN_PICTURE,
)
}
if (showDebugInfo) Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical)
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium),
) {
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) {
Text(
text = "分辨率: ${session.width}x${session.height}",
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
@SuppressLint("DefaultLocale")
Text(
text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}",
color = Color.White,
fontSize = 13.sp,
)
Text(
text = "触点: $activeTouchCount",
color = Color.White,
fontSize = 13.sp,
)
if (activeTouchDebug.isNotEmpty()) Text(
text = activeTouchDebug,
color = Color.White,
fontSize = 13.sp,
)
}
}
}
DisposableEffect(Unit) {
onDispose {
onVideoBoundsInWindowChanged(null)
}
}
}

View File

@@ -2,7 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.SystemClock
import android.widget.Toast
@@ -24,7 +23,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -49,6 +47,7 @@ import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
@@ -86,7 +85,6 @@ sealed interface RootScreen : NavKey {
data object Advanced : RootScreen
data object About : RootScreen
data object VirtualButtonOrder : RootScreen
data object Fullscreen : RootScreen
}
@Composable
@@ -95,22 +93,11 @@ fun MainScreen() {
val context = LocalContext.current
val appContext = context.applicationContext
val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
// Scopes
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
// Core services
val nativeCore = remember(appContext) {
NativeCoreFacade.get(appContext)
}
val adbService = remember(appContext) {
NativeAdbService(appContext)
}
// Global controllers provided to the compose tree
val snackHostState = remember { SnackbarHostState() }
val snackbarController = remember(scope, snackHostState) {
@@ -131,17 +118,8 @@ fun MainScreen() {
val rootBackStack = remember { mutableStateListOf<NavKey>(RootScreen.Home) }
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
DisposableEffect(activity) {
onDispose {
activity?.requestedOrientation = initialOrientation
}
}
// Scroll behaviors
val devicesPageScrollBehavior = MiuixScrollBehavior(
canScroll = { currentTab == MainBottomTabDestination.Device })
@@ -166,11 +144,6 @@ fun MainScreen() {
},
)
}
val fullscreenNavigationState = remember {
FullscreenNavigationState(
setOrientation = { fullscreenOrientation = it }
)
}
// Shared settings bundles
val asBundleShared by appSettings.bundleState.collectAsState()
@@ -228,19 +201,20 @@ fun MainScreen() {
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
val scrcpy = remember(
appContext,
adbService,
customServerUri,
customServerVersion,
serverRemotePath,
) {
Scrcpy(
appContext = appContext,
adbService = adbService,
customServerUri = customServerUri,
serverVersion = customServerVersion,
serverRemotePath = serverRemotePath,
)
).also {
AppRuntime.scrcpy = it
}
}
val currentSession by scrcpy.currentSessionState.collectAsState()
// Side-effect launchers and composition locals
@@ -305,20 +279,18 @@ fun MainScreen() {
scope.launch {
withContext(Dispatchers.IO) {
runCatching { scrcpy.stop() }
runCatching { adbService.disconnect() }
runCatching { NativeAdbService.disconnect() }
}
activity?.finish()
}
}
}
BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) {
BackHandler {
handleBackNavigation()
}
PredictiveBackHandler(
enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen
) { progress ->
PredictiveBackHandler(enabled = canNavigateBack) { progress ->
try {
progress.collect { }
handleBackNavigation()
@@ -327,39 +299,19 @@ fun MainScreen() {
}
}
// Fullscreen route can force orientation based on stream ratio; all other routes are portrait.
LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) {
val targetOrientation = when (currentRootScreen) {
is RootScreen.Fullscreen -> fullscreenOrientation
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
activity?.requestedOrientation = targetOrientation
}
DisposableEffect(nativeCore, scrcpy) {
DisposableEffect(scrcpy) {
val listener: (Int, Int) -> Unit = { width, height ->
scrcpy.updateCurrentSessionSize(width, height)
}
nativeCore.addVideoSizeListener(listener)
NativeCoreFacade.addVideoSizeListener(listener)
onDispose {
nativeCore.removeVideoSizeListener(listener)
}
}
LaunchedEffect(currentRootScreen, currentSession?.width, currentSession?.height) {
if (currentRootScreen is RootScreen.Fullscreen) {
val session = currentSession ?: return@LaunchedEffect
fullscreenOrientation =
if (session.width >= session.height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
NativeCoreFacade.removeVideoSizeListener(listener)
}
}
LaunchedEffect(asBundle.adbKeyName) {
adbService.keyName = asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
NativeAdbService.keyName =
asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
}
val rootEntryProvider = entryProvider<NavKey> {
@@ -400,10 +352,8 @@ fun MainScreen() {
saveableStateHolder.SaveableStateProvider(tab.name) {
when (tab) {
MainBottomTabDestination.Device -> DeviceTabScreen(
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
scrollBehavior = devicesPageScrollBehavior,
scrcpy = scrcpy,
onOpenReorderDevices = { showReorderDevices = true },
)
@@ -439,17 +389,6 @@ fun MainScreen() {
)
}
entry(RootScreen.Fullscreen) {
FullscreenControlScreen(
scrcpy = scrcpy,
nativeCore = nativeCore,
onVideoSizeChanged = { width, height ->
fullscreenOrientation =
if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
},
)
}
}
val rootEntries = rememberDecoratedNavEntries(
@@ -469,7 +408,6 @@ fun MainScreen() {
LocalRootNavigator provides rootNavigator,
LocalSnackbarController provides snackbarController,
LocalAppHaptics provides haptics,
LocalFullscreenNavigationState provides fullscreenNavigationState,
LocalServerPicker provides serverPicker,
) {
NavDisplay(

View File

@@ -10,11 +10,3 @@ class RootNavigator(
val LocalRootNavigator = staticCompositionLocalOf<RootNavigator> {
error("No RootNavigator provided")
}
class FullscreenNavigationState(
val setOrientation: (Int) -> Unit,
)
val LocalFullscreenNavigationState = staticCompositionLocalOf<FullscreenNavigationState> {
error("No FullscreenNavigationState provided")
}

View File

@@ -471,6 +471,7 @@ fun SettingsPage(
item {
SectionSmallTitle("")
Card {
// TODO: 进入时无视自动更新检查的 CD, 主动触发一次
ArrowPreference(
title = "关于",
summary = updateSummary,

View File

@@ -0,0 +1,127 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.pm.ActivityInfo
import android.graphics.Rect
import android.util.Rational
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.app.PictureInPictureParamsCompat
import io.github.miuzarte.scrcpyforandroid.StreamActivity
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
@Composable
fun StreamScreen(activity: StreamActivity) {
val scrcpy = remember { AppRuntime.scrcpy!! }
val asBundle by Storage.appSettings.bundleState.collectAsState()
val isInPip by activity.pipModeState.collectAsState()
val currentSession by scrcpy.currentSessionState.collectAsState()
var pipSourceRectHint by remember { mutableStateOf<Rect?>(null) }
var lastPipAspectRatio by remember { mutableStateOf<Rational?>(null) }
var lastPipOrientationLandscape by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(currentSession) {
if (currentSession == null) {
activity.finish()
}
}
DisposableEffect(isInPip) {
VideoOutputTargetState.set(
if (isInPip)
VideoOutputTarget.PICTURE_IN_PICTURE
else
VideoOutputTarget.FULLSCREEN
)
onDispose {
VideoOutputTargetState.set(VideoOutputTarget.PREVIEW)
}
}
LaunchedEffect(
activity, isInPip,
currentSession?.width, currentSession?.height,
) {
val session = currentSession ?: return@LaunchedEffect
val isLandscape = session.width >= session.height
if (lastPipAspectRatio != null && lastPipOrientationLandscape == isLandscape) {
// 一定要只在视频比例变更时才更新,
// .setAspectRatio() 多次传递相同的值时,
// 内部会自行应用其倒数
return@LaunchedEffect
}
lastPipOrientationLandscape = isLandscape
val pipAspectRatio = Rational(
session.width.coerceAtLeast(1),
session.height.coerceAtLeast(1),
).also { ratio ->
lastPipAspectRatio = ratio
}
activity.configurePictureInPicture(
enabled = true,
params = PictureInPictureParamsCompat.Builder()
.setEnabled(true)
.setAspectRatio(pipAspectRatio)
.setSourceRectHint(
if (!isInPip) pipSourceRectHint else null
)
.setSeamlessResizeEnabled(true)
.setCloseAction(activity.pipStopAction)
.build(),
)
}
val themeMode =
when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) {
1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight
2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark
else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem
}
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
val haptics = rememberAppHaptics()
MiuixTheme(controller = themeController) {
CompositionLocalProvider(
LocalAppHaptics provides haptics,
) {
FullscreenControlScreen(
scrcpy = scrcpy,
onBack = activity::finish,
isInPip = isInPip,
onVideoSizeChanged = { width, height ->
// 只在全屏时跟随视频方向
if (!isInPip) {
activity.requestedOrientation =
if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
},
onVideoBoundsInWindowChanged = {
// 记录下一次进入 PiP 时可用的 sourceRectHint
pipSourceRectHint = it
},
)
}
}
}

View File

@@ -52,14 +52,14 @@ import kotlin.random.nextUInt
*/
class Scrcpy(
private val appContext: Context,
private val adbService: NativeAdbService,
private val serverAsset: String = DEFAULT_SERVER_ASSET,
private val customServerUri: String? = null,
private val serverVersion: String = DEFAULT_SERVER_VERSION,
private val serverRemotePath: String = DEFAULT_REMOTE_PATH,
) {
private val session = Session(adbService)
private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext)
private val session = Session()
private val _currentSessionState = MutableStateFlow<Session.SessionInfo?>(null)
val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow()
@@ -144,7 +144,7 @@ class Scrcpy(
// Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) {
nativeCore.onScrcpySessionStarted(info, session)
NativeCoreFacade.onScrcpySessionStarted(info, session)
}
// Setup audio player
@@ -192,7 +192,7 @@ class Scrcpy(
Log.i(TAG, "stop(): Stopping scrcpy session")
return@withContext try {
nativeCore.onScrcpySessionStopped()
NativeCoreFacade.onScrcpySessionStopped()
session.clearVideoConsumer()
session.clearAudioConsumer()
session.stop()
@@ -208,15 +208,64 @@ class Scrcpy(
}
}
suspend fun close() {
stop()
adbService.close()
}
fun isStarted(): Boolean = isRunning && session.isStarted()
fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value
suspend fun injectKeycode(
action: Int,
keycode: Int,
repeat: Int = 0,
metaState: Int = 0,
) = session.injectKeycode(
action = action,
keycode = keycode,
repeat = repeat,
metaState = metaState,
)
suspend fun injectText(text: String) = session.injectText(text)
suspend fun injectTouch(
action: Int,
pointerId: Long,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
actionButton: Int = 0,
buttons: Int = 0,
) = session.injectTouch(
action = action,
pointerId = pointerId,
x = x,
y = y,
screenWidth = screenWidth,
screenHeight = screenHeight,
pressure = pressure,
actionButton = actionButton,
buttons = buttons,
)
suspend fun injectScroll(
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int,
) = session.injectScroll(
x = x,
y = y,
screenWidth = screenWidth,
screenHeight = screenHeight,
hScroll = hScroll,
vScroll = vScroll,
buttons = buttons,
)
fun updateCurrentSessionSize(width: Int, height: Int) {
val current = _currentSessionState.value ?: return
if (current.width == width && current.height == height) return
@@ -399,7 +448,7 @@ class Scrcpy(
extractUriToCache(customServerUri.toUri())
}
adbService.push(serverJar.toPath(), serverRemotePath)
NativeAdbService.push(serverJar.toPath(), serverRemotePath)
val scid = generateScid()
val options = ClientOptions(
@@ -419,7 +468,7 @@ class Scrcpy(
)
Log.i(TAG, "listOptions(): cmd=$serverCommand")
adbService.shell("$serverCommand 2>&1")
NativeAdbService.shell("$serverCommand 2>&1")
}
private fun logListPreview(list: ListOptions, countSummary: String, output: String) {
@@ -553,7 +602,7 @@ class Scrcpy(
options: ClientOptions,
scid: UInt,
): Session.SessionInfo {
adbService.push(serverJar.toPath(), serverRemotePath)
NativeAdbService.push(serverJar.toPath(), serverRemotePath)
val serverParams = options.toServerParams(scid)
@@ -605,7 +654,7 @@ class Scrcpy(
* Session manager for scrcpy protocol.
* Handles socket communication, video/audio streaming, and control input.
*/
class Session(private val adbService: NativeAdbService) {
class Session {
private val mutex = Mutex()
@Volatile
@@ -635,7 +684,7 @@ class Scrcpy(
val socketName = socketNameFor(scid.toInt())
try {
val serverStream = adbService.openShellStream(serverCommand)
val serverStream = NativeAdbService.openShellStream(serverCommand)
val serverLogThread = startServerLogThread(serverStream, socketName)
Thread.sleep(SERVER_BOOT_DELAY_MS)
@@ -1045,7 +1094,7 @@ class Scrcpy(
var lastEx: Exception? = null
repeat(CONNECT_RETRY_COUNT) { attempt ->
try {
val stream = adbService.openAbstractSocket(socketName)
val stream = NativeAdbService.openAbstractSocket(socketName)
if (expectDummyByte) {
val value = stream.inputStream.read()
if (value < 0) {

View File

@@ -8,6 +8,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* TouchEventHandler
*
* Purpose:
* - Handles touch event processing for fullscreen control screen
* - Manages pointer tracking, coordinate mapping, and touch injection
*/
class TouchEventHandler(
private val coroutineScope: CoroutineScope,
private val session: Scrcpy.Session.SessionInfo,

View File

@@ -0,0 +1,20 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
// 用于不同 activity 之间传递实例
object AppRuntime {
private lateinit var appContext: Context
@RequiresApi(Build.VERSION_CODES.R)
fun init(context: Context) {
appContext = context.applicationContext
AdbMdnsDiscoverer.init(appContext)
}
var scrcpy: Scrcpy? = null
}

View File

@@ -0,0 +1,35 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking
// MIUI 不进
class PictureInPictureActionReceiver : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != ACTION_STOP_SCRCPY) return
val pendingResult = goAsync()
Thread {
try {
val appContext = context.applicationContext
Storage.init(appContext)
AppRuntime.init(appContext)
runBlocking {
AppRuntime.scrcpy?.stop()
}
} finally {
pendingResult.finish()
}
}.start()
}
companion object {
const val ACTION_STOP_SCRCPY =
"io.github.miuzarte.scrcpyforandroid.action.STOP_SCRCPY_FROM_PIP"
}
}

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.graphics.SurfaceTexture
import android.view.Surface
import android.view.TextureView
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
@@ -40,7 +39,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -51,20 +49,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
@@ -75,7 +72,6 @@ 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.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
@@ -250,7 +246,6 @@ internal fun PairingCard(
@Composable
internal fun PreviewCard(
sessionInfo: Scrcpy.Session.SessionInfo?,
nativeCore: NativeCoreFacade,
previewHeightDp: Int,
controlsVisible: Boolean,
onTapped: () -> Unit,
@@ -258,6 +253,27 @@ internal fun PreviewCard(
) {
val haptics = rememberAppHaptics()
val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls")
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> VideoOutputTargetState.set(VideoOutputTarget.PREVIEW)
Lifecycle.Event.ON_STOP ->
if (VideoOutputTargetState.current.value == VideoOutputTarget.PREVIEW)
VideoOutputTargetState.set(VideoOutputTarget.NONE)
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
if (VideoOutputTargetState.current.value == VideoOutputTarget.PREVIEW) {
VideoOutputTargetState.set(VideoOutputTarget.NONE)
}
}
}
Card {
Box(
@@ -290,8 +306,8 @@ internal fun PreviewCard(
) {
ScrcpyVideoSurface(
modifier = Modifier.fillMaxSize(),
nativeCore = nativeCore,
session = sessionInfo,
target = VideoOutputTarget.PREVIEW,
)
}
}
@@ -304,7 +320,7 @@ internal fun PreviewCard(
) {
Button(
onClick = {
if (alpha > 0.1) {
if (alpha > 0.1f) {
haptics.contextClick()
onOpenFullscreen()
}
@@ -465,6 +481,7 @@ internal fun ConfigPanel(
?.takeIf { it >= 0 }
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) }
},
enabled = !sessionStarted,
)
}
@@ -721,142 +738,6 @@ private fun PairingDialog(
)
}
/**
* TouchEventHandler
*
* Purpose:
* - Handles touch event processing for fullscreen control screen
* - Manages pointer tracking, coordinate mapping, and touch injection
*/
/**
* FullscreenControlScreen
*
* Purpose:
* - Presents a fullscreen interactive touch surface that maps Compose touch events
* to device coordinates and injects them via [onInjectTouch].
* - Responsible for pointer tracking, multi-touch mapping, coordinate normalization,
* and lifetime of synthetic touch events sent to the device.
*
* Concurrency and side-effects:
* - All heavy computations are local to the UI thread; injection itself is a quick
* callback (`onInjectTouch`) which delegates to native code elsewhere — keep that
* callback lightweight.
* - Use `pointerInteropFilter` to receive raw MotionEvent instances for precise
* multi-touch handling and to map Android pointer IDs to device pointers.
*/
@Composable
fun FullscreenControlScreen(
session: Scrcpy.Session.SessionInfo,
nativeCore: NativeCoreFacade,
onDismiss: () -> Unit,
showDebugInfo: Boolean,
currentFps: Float,
enableBackHandler: Boolean = true,
onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
val coroutineScope = rememberCoroutineScope()
var touchAreaSize by remember { mutableStateOf(IntSize.Zero) }
val activePointerIds = remember { linkedSetOf<Int>() }
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
val activePointerDevicePositions = remember { linkedMapOf<Int, Pair<Int, Int>>() }
val pointerLabels = remember { linkedMapOf<Int, Int>() }
var nextPointerLabel by remember { mutableIntStateOf(1) }
var activeTouchCount by remember { mutableIntStateOf(0) }
var activeTouchDebug by remember { mutableStateOf("") }
val touchEventHandler = remember(session, touchAreaSize) {
TouchEventHandler(
coroutineScope = coroutineScope,
session = session,
touchAreaSize = touchAreaSize,
activePointerIds = activePointerIds,
activePointerPositions = activePointerPositions,
activePointerDevicePositions = activePointerDevicePositions,
pointerLabels = pointerLabels,
nextPointerLabel = nextPointerLabel,
onInjectTouch = onInjectTouch,
onActiveTouchCountChanged = { activeTouchCount = it },
onActiveTouchDebugChanged = { activeTouchDebug = it },
onNextPointerLabelChanged = { nextPointerLabel = it },
)
}
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInteropFilter { event ->
touchEventHandler.handleMotionEvent(event)
}
.onSizeChanged { touchAreaSize = it },
) {
val sessionAspect = if (session.height == 0) {
16f / 9f
} else {
session.width.toFloat() / session.height.toFloat()
}
val containerAspect = maxWidth.value / maxHeight.value
val fittedModifier = if (sessionAspect > containerAspect) {
Modifier
.fillMaxWidth()
.aspectRatio(sessionAspect)
} else {
Modifier
.fillMaxHeight()
.aspectRatio(sessionAspect)
}
Box(
modifier = Modifier
.align(Alignment.Center)
.then(fittedModifier),
) {
ScrcpyVideoSurface(
modifier = Modifier.fillMaxSize(),
nativeCore = nativeCore,
session = session,
)
}
if (showDebugInfo) {
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical)
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium),
) {
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) {
Text(
text = "分辨率: ${session.width}x${session.height}",
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
@SuppressLint("DefaultLocale")
Text(
text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}",
color = Color.White,
fontSize = 13.sp,
)
Text(
text = "触点: $activeTouchCount",
color = Color.White,
fontSize = 13.sp,
)
if (activeTouchDebug.isNotEmpty()) Text(
text = activeTouchDebug,
color = Color.White,
fontSize = 13.sp,
)
}
}
}
}
}
/**
* ScrcpyVideoSurface
*
@@ -878,19 +759,38 @@ fun FullscreenControlScreen(
* referencing stale surfaces.
*/
@Composable
private fun ScrcpyVideoSurface(
fun ScrcpyVideoSurface(
modifier: Modifier,
nativeCore: NativeCoreFacade,
session: Scrcpy.Session.SessionInfo?,
target: VideoOutputTarget,
) {
var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val lifecycleOwner = LocalLifecycleOwner.current
val currentTarget by VideoOutputTargetState.current.collectAsState()
LaunchedEffect(session, currentSurface) {
LaunchedEffect(session, currentSurface, currentTarget, target) {
val surface = currentSurface
if (session != null && surface != null && surface.isValid) {
nativeCore.attachVideoSurface(surface)
if (currentTarget == target && session != null && surface != null && surface.isValid) {
NativeCoreFacade.attachVideoSurface(surface)
}
}
DisposableEffect(lifecycleOwner, session, currentSurface, currentTarget, target) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
val surface = currentSurface
if (currentTarget == target && session != null && surface != null && surface.isValid) {
scope.launch {
NativeCoreFacade.attachVideoSurface(surface)
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
@@ -898,7 +798,12 @@ private fun ScrcpyVideoSurface(
onDispose {
val surface = currentSurface
if (surface != null) {
taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) }
taskScope.launch {
NativeCoreFacade.detachVideoSurface(
surface,
releaseDecoder = false
)
}
}
}
}
@@ -917,9 +822,9 @@ private fun ScrcpyVideoSurface(
val newSurface = Surface(surfaceTexture)
currentSurface = newSurface
// Register immediately when surface becomes available
if (session != null) {
if (currentTarget == target && session != null) {
scope.launch {
nativeCore.attachVideoSurface(newSurface)
NativeCoreFacade.attachVideoSurface(newSurface)
}
}
}
@@ -934,7 +839,7 @@ private fun ScrcpyVideoSurface(
val surface = currentSurface
if (surface != null) {
taskScope.launch {
nativeCore.detachVideoSurface(surface, releaseDecoder = false)
NativeCoreFacade.detachVideoSurface(surface, releaseDecoder = false)
}
surface.release()
currentSurface = null

View File

@@ -0,0 +1,21 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
enum class VideoOutputTarget {
NONE,
PREVIEW,
FULLSCREEN,
PICTURE_IN_PICTURE,
}
object VideoOutputTargetState {
private val _current = MutableStateFlow(VideoOutputTarget.NONE)
val current: StateFlow<VideoOutputTarget> = _current.asStateFlow()
fun set(target: VideoOutputTarget) {
_current.value = target
}
}

View File

@@ -12,6 +12,7 @@ espressoCore = "3.7.0"
miuix = "0.9.0"
material = "1.13.0"
runtime = "1.10.5"
androidxCorePip = "1.0.0-alpha02"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -36,6 +37,7 @@ miuix-preference = { group = "top.yukonga.miuix.kmp", name = "miuix-preference",
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" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "androidxCorePip" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }