Compare commits

...

10 Commits

Author SHA1 Message Date
db6c4b9bb8 feat():实现命令行启动 2026-07-16 21:58:53 +08:00
491badd2aa feat():悬浮窗方案 替代 系统PiP方案 2026-07-15 21:34:27 +08:00
4c7b24a4d5 feat(env):update compile env. 2026-07-15 20:12:38 +08:00
f7ff48ff6c Merge branch 'main' of https://github.com/Miuzarte/ScrcpyForAndroid 2026-07-15 19:25:28 +08:00
renovate[bot]
b1ba9fcbea chore(deps): update actions/setup-java action to v5 (#58)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:46:12 +08:00
renovate[bot]
85f4e50aad chore(deps): update actions/checkout action to v7 (#57)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:46:01 +08:00
renovate[bot]
190da2b0ab chore(deps): update actions/cache action to v6 (#56)
* chore(deps): ignore miuix in renovatebot

* chore(deps): update actions/cache action to v6

---------

Co-authored-by: Miuzarte <982809597@qq.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:45:47 +08:00
renovate[bot]
f92467700c fix(deps): update dependency org.conscrypt:conscrypt-android to v2.6.1 (#55)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:45:09 +08:00
renovate[bot]
12f9effd8f fix(deps): update dependency org.bouncycastle:bcpkix-jdk18on to v1.85 (#54)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:44:40 +08:00
Miuzarte
c9cb5b1034 fix: packaging excludes 2026-07-14 14:42:02 +08:00
17 changed files with 738 additions and 208 deletions

View File

@@ -19,19 +19,19 @@ jobs:
RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: true
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- uses: actions/cache@v4
- uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -40,7 +40,7 @@ jobs:
key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-deps-
- uses: actions/cache@v4
- uses: actions/cache@v6
with:
path: |
~/.gradle/caches/build-cache-*
@@ -98,19 +98,19 @@ jobs:
RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: true
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- uses: actions/cache@v4
- uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -119,7 +119,7 @@ jobs:
key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-deps-
- uses: actions/cache@v4
- uses: actions/cache@v6
with:
path: |
~/.gradle/caches/build-cache-*

View File

@@ -11,19 +11,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: true
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- uses: actions/cache@v4
- uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -32,7 +32,7 @@ jobs:
key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-deps-
- uses: actions/cache@v4
- uses: actions/cache@v6
with:
path: |
~/.gradle/caches/build-cache-*

View File

@@ -106,6 +106,17 @@ android {
buildConfig = true
}
packaging {
resources {
excludes += listOf(
"META-INF/LICENSE.md",
"META-INF/LICENSE",
"META-INF/NOTICE.md",
"META-INF/NOTICE",
)
}
}
externalNativeBuild {
cmake {
path = file("src/main/jni/CMakeLists.txt")

View File

@@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application
android:allowBackup="true"
@@ -36,8 +37,7 @@
android:name=".StreamActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
android:exported="false"
android:resizeableActivity="true"
android:supportsPictureInPicture="true" />
android:resizeableActivity="true" />
<activity
android:name=".LockscreenPasswordActivity"

View File

@@ -10,6 +10,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
import io.github.miuzarte.scrcpyforandroid.password.BiometricGate
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
@@ -44,6 +45,11 @@ class MainActivity: FragmentActivity() {
AppRuntime.init(applicationContext)
AppScreenOn.register(window)
// 解析命令行启动参数adb shell am start --es host ...
AdbLaunchParams.fromIntent(intent)?.let {
AppRuntime.adbLaunchParams = it
}
runBlocking {
PasswordRepository.refresh()
val cached = getAppLanguageTag(applicationContext)

View File

@@ -1,150 +1,81 @@
package io.github.miuzarte.scrcpyforandroid
import android.R.drawable
import android.app.PictureInPictureUiState
import android.app.RemoteAction
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.core.app.PictureInPictureParamsCompat.Builder
import androidx.core.content.ContextCompat
import androidx.core.pip.BasicPictureInPicture
import androidx.fragment.app.FragmentActivity
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import io.github.miuzarte.scrcpyforandroid.widgets.FloatingVideoWindow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
class StreamActivity: FragmentActivity() {
private val basicPip by lazy { BasicPictureInPicture(this, ContextCompat.getMainExecutor(this)) }
private val pipActionReceiver = PictureInPictureActionReceiver()
private var isPipActionReceiverRegistered = false
private val floatingWindow by lazy { FloatingVideoWindow(this) }
private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// 是否处于 pip
// 回到全屏时会因重建而变回初始值
private val _pipModeState = MutableStateFlow(false)
val pipModeState: StateFlow<Boolean> = _pipModeState
val pipStopAction: RemoteAction by lazy {
RemoteAction(
Icon.createWithResource(this, drawable.ic_menu_close_clear_cancel),
getString(R.string.password_stop_mirroring),
getString(R.string.password_stop_mirroring),
PictureInPictureActionReceiver.createPendingIntent(this),
)
}
// 每次 进出全屏/进出画中画
// 都会重建 activity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
currentActivityRef = WeakReference(this)
AppScreenOn.register(window)
registerPipActionReceiver()
// 声明要画中画
basicPip.setEnabled(true)
setContent {
StreamScreen(activity = this)
}
/*
// 可能以后有用
basicPip.addOnPictureInPictureEventListener(
executor = mainExecutor,
listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener {
override fun onPictureInPictureEvent(
event: PictureInPictureDelegate.Event,
config: Configuration?,
) {
// MIUI 只有这些事件
when (event) {
PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> {}
PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {}
PictureInPictureDelegate.Event.STASHED -> {}
PictureInPictureDelegate.Event.UNSTASHED -> {}
// 收不到
// PictureInPictureDelegate.Event.ENTERED -> {}
// PictureInPictureDelegate.Event.EXITED -> {}
}
override fun onStart() {
super.onStart()
// 回到前台时隐藏悬浮窗
floatingWindow.hide()
}
override fun onStop() {
super.onStop()
if (AppRuntime.scrcpy?.currentSessionState?.value != null) {
// 进入后台时显示悬浮窗
floatingWindow.show(
onClose = {
// 关闭:停止 scrcpy 会话
activityScope.launch(Dispatchers.IO) {
AppRuntime.scrcpy?.stop(Scrcpy.StopReason.USER)
}
finish()
},
onFullscreen = {
// 全屏:重新打开 StreamActivity
if (!isFinishing && !isDestroyed) {
// Activity 仍在,直接将其带到前台
startActivity(createIntent(this).apply {
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
})
} else {
// Activity 已销毁,创建新的
startActivity(createIntent(this))
}
},
)
*/
}
fun configurePip(block: Builder.() -> Builder) =
basicPip.setPictureInPictureParams(Builder().block().build())
}
override fun onDestroy() {
currentActivityRef?.get()
?.takeIf { it === this }
?.let { currentActivityRef = null }
AppScreenOn.unregister(window)
unregisterPipActionReceiver()
floatingWindow.destroy()
activityScope.cancel()
super.onDestroy()
}
/*
// 回到全屏也会停止, 暂时不做
override fun onDestroy() {
super.onDestroy()
if (_pipModeState.value) {
Thread {
runBlocking {
AppRuntime.scrcpy?.stop()
}
}.start()
}
}
*/
//- onPictureInPictureModeChanged
//+ onPictureInPictureUiStateChanged
//- onUserLeaveHint
override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {
super.onPictureInPictureUiStateChanged(pipState)
_pipModeState.value = true
/*
when {
// 进入画中画
pipState.isTransitioningToPip -> {}
// 收进边缘
pipState.isStashed -> {}
}
*/
}
private fun registerPipActionReceiver() {
if (isPipActionReceiverRegistered) return
ContextCompat.registerReceiver(
this,
pipActionReceiver,
PictureInPictureActionReceiver.createIntentFilter(),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
isPipActionReceiverRegistered = true
}
private fun unregisterPipActionReceiver() {
if (!isPipActionReceiverRegistered) return
unregisterReceiver(pipActionReceiver)
isPipActionReceiverRegistered = false
}
companion object {
private var currentActivityRef: WeakReference<StreamActivity>? = null
@@ -153,9 +84,8 @@ class StreamActivity: FragmentActivity() {
}
fun dismissActivePictureInPicture() {
currentActivityRef?.get()
?.takeIf { it.isInPictureInPictureMode }
?.finish()
// 悬浮窗模式:不再使用系统 PiP改为关闭悬浮窗
currentActivityRef?.get()?.floatingWindow?.hide()
}
}
}

View File

@@ -0,0 +1,72 @@
package io.github.miuzarte.scrcpyforandroid.models
import android.content.Intent
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* 从命令行 Intent extras 中解析的启动参数。
*
* 使用方式adb shell
* ```
* adb shell am start -n io.github.miuzarte.scrcpyforandroid/.MainActivity \
* --es host "192.168.1.100" \
* --ei port 5555 \
* --ei maxSize 1920 \
* --ei videoBitRate 8000000 \
* --es maxFps "60" \
* --ez fullscreen true \
* --es startApp "com.example.app"
* ```
*/
@Parcelize
data class AdbLaunchParams(
/** 目标设备 IP/主机名(必填) */
val host: String,
/** ADB 端口,默认 5555 */
val port: Int = 5555,
/** 视频最大分辨率0 表示不限制 */
val maxSize: Int = 0,
/** 视频码率bps0 表示使用默认值 */
val videoBitRate: Int = 0,
/** 最大帧率,空字符串表示不限制 */
val maxFps: String = "",
/** 启动后自动进入全屏 */
val fullscreen: Boolean = false,
/** 在远程设备上启动指定应用包名 */
val startApp: String = "",
/** 显示触摸点 */
val showTouches: Boolean = false,
/** 启动后关闭设备屏幕 */
val turnScreenOff: Boolean = false,
/** 启用触控操作,默认 true */
val control: Boolean = true,
/** 启用视频流,默认 true */
val video: Boolean = true,
/** 启用音频流,默认 true */
val audio: Boolean = true,
/** 关闭后断掉 ADB 连接 */
val killAdbOnClose: Boolean = false,
) : Parcelable {
companion object {
fun fromIntent(intent: Intent?): AdbLaunchParams? {
intent ?: return null
val host = intent.getStringExtra("host")?.takeIf { it.isNotBlank() } ?: return null
return AdbLaunchParams(
host = host,
port = intent.getIntExtra("port", 5555),
maxSize = intent.getIntExtra("maxSize", 0),
videoBitRate = intent.getIntExtra("videoBitRate", 0),
maxFps = intent.getStringExtra("maxFps") ?: "",
fullscreen = intent.getBooleanExtra("fullscreen", false),
startApp = intent.getStringExtra("startApp") ?: "",
showTouches = intent.getBooleanExtra("showTouches", false),
turnScreenOff = intent.getBooleanExtra("turnScreenOff", false),
control = intent.getBooleanExtra("control", true),
video = intent.getBooleanExtra("video", true),
audio = intent.getBooleanExtra("audio", true),
killAdbOnClose = intent.getBooleanExtra("killAdbOnClose", false),
)
}
}
}

View File

@@ -230,6 +230,7 @@ internal fun DeviceTabPage(
LaunchedEffect(Unit) { viewModel.startAutoReconnectLoop() }
LaunchedEffect(Unit) { viewModel.startProfileIdSync() }
LaunchedEffect(Unit) { viewModel.startRecentTasksAutoRefresh() }
LaunchedEffect(Unit) { viewModel.processLaunchParams() }
fun openFullscreenControl() {
if (viewModel.shouldOpenFullscreenCompat())

View File

@@ -7,9 +7,11 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.StreamActivity
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.*
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
@@ -512,6 +514,7 @@ internal class DeviceTabViewModel(
autoStartScrcpy: Boolean = false,
autoEnterFullScreen: Boolean = false,
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
scrcpyOptionsOverride: ((ClientOptions) -> ClientOptions)? = null,
) {
val connected = connectionController.handleAdbConnected(host, port, scrcpyProfileId)
val info = connected.info
@@ -551,7 +554,10 @@ internal class DeviceTabViewModel(
if (autoStartScrcpy && sessionInfo.value == null) {
runBusy(EventLogMessage.Resource(R.string.vm_start_scrcpy)) {
startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen)
startScrcpySession(
openFullscreen = autoStartScrcpy && autoEnterFullScreen,
extraOptions = scrcpyOptionsOverride,
)
}
}
}
@@ -559,13 +565,21 @@ internal class DeviceTabViewModel(
suspend fun startScrcpySession(
openFullscreen: Boolean = false,
startAppOverride: String? = null,
extraOptions: ((ClientOptions) -> ClientOptions)? = null,
) {
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value)
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
val resolvedOptions = startAppOverride
val resolvedOptions = extraOptions?.invoke(
startAppOverride
?.takeIf { it.isNotBlank() }
?.let { options.copy(startApp = it) }
?: options
) ?: (
startAppOverride
?.takeIf { it.isNotBlank() }
?.let { options.copy(startApp = it) }
?: options
)
val session = scrcpy.start(resolvedOptions)
_pendingScrollToPreview.value = resolvedOptions.video && resolvedOptions.videoPlayback
@@ -786,6 +800,52 @@ internal class DeviceTabViewModel(
}
}
/**
* 处理命令行启动参数:连接到指定设备并自动启动 scrcpy。
* 从 AppRuntime.adbLaunchParams 读取参数,用后即清。
*/
fun processLaunchParams() {
val params = AppRuntime.adbLaunchParams ?: return
AppRuntime.adbLaunchParams = null // consume
runAdbConnect(
label = EventLogMessage.Raw("Auto-connect"),
) {
disconnectCurrentTargetBeforeConnecting(params.host, params.port)
try {
connectWithTimeout(params.host, params.port)
handleAdbConnected(
host = params.host,
port = params.port,
autoStartScrcpy = true,
autoEnterFullScreen = params.fullscreen,
scrcpyOptionsOverride = { options ->
options.copy(
maxSize =
if (params.maxSize > 0) params.maxSize.toUShort() else options.maxSize,
videoBitRate =
if (params.videoBitRate > 0) params.videoBitRate else options.videoBitRate,
maxFps = params.maxFps.ifBlank { options.maxFps },
showTouches = params.showTouches,
fullscreen = params.fullscreen,
control = params.control,
video = params.video,
audio = params.audio,
turnScreenOff = params.turnScreenOff,
killAdbOnClose = params.killAdbOnClose,
startApp = params.startApp.ifBlank { options.startApp },
)
},
)
connectionController.updateQuickConnected(false)
} catch (error: Exception) {
connectionController.markConnectionFailed(error)
logEvent(R.string.vm_adb_connection_failed, level = Log.ERROR, error = error)
AppRuntime.snackbar(R.string.vm_adb_connection_failed)
}
}
}
fun onDisconnectCurrent(target: ConnectionTarget?) {
runAdbConnect(EventLogMessage.Resource(R.string.vm_disconnect_adb)) {
target?.let {

View File

@@ -56,7 +56,6 @@ import top.yukonga.miuix.kmp.basic.Text
fun FullscreenControlScreen(
scrcpy: Scrcpy,
onBack: () -> Unit,
isInPip: Boolean,
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
onVideoBoundsInWindowChanged: (Rect?) -> Unit,
) {
@@ -388,15 +387,17 @@ fun FullscreenControlScreen(
.padding(contentPadding),
) {
val session = currentSession ?: return@Box
var touchEventHandlerRef by remember { mutableStateOf<TouchEventHandler?>(null) }
FullscreenControlPage(
scrcpy = scrcpy,
session = session,
onDismiss = onBack,
showDebugInfo = fullscreenDebugInfo && !isInPip,
showDebugInfo = fullscreenDebugInfo,
currentFps = currentFps,
imeRequestToken = imeRequestToken,
enableBackHandler = false,
interactive = !isInPip,
interactive = true,
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
onImeCommitText = ::commitImeText,
onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons ->
@@ -417,9 +418,10 @@ fun FullscreenControlScreen(
onBackOrScreenOn = { action ->
withContext(Dispatchers.IO) { scrcpy.pressBackOrTurnScreenOn(action) }
},
onTouchEventHandlerCreated = { touchEventHandlerRef = it },
)
if (showFullscreenVirtualButtons && !isInPip) {
if (showFullscreenVirtualButtons) {
bar.Fullscreen(
modifier = Modifier.align(
when (fullscreenVirtualButtonDock) {
@@ -439,7 +441,7 @@ fun FullscreenControlScreen(
)
}
if (asBundle.showFullscreenFloatingButton && !isInPip) {
if (asBundle.showFullscreenFloatingButton) {
bar.FloatingBall(
actions = floatingActions,
modifier = Modifier.fillMaxSize(),
@@ -626,6 +628,7 @@ fun FullscreenControlPage(
buttons: Int,
) -> Unit,
onBackOrScreenOn: suspend (action: Int) -> Unit,
onTouchEventHandlerCreated: ((TouchEventHandler?) -> Unit)? = null,
) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
@@ -661,6 +664,13 @@ fun FullscreenControlPage(
)
}
DisposableEffect(touchEventHandler) {
onTouchEventHandlerCreated?.invoke(touchEventHandler)
onDispose {
onTouchEventHandlerCreated?.invoke(null)
}
}
val resizeDebouncer = remember {
Debouncer(300L) { w, h ->
coroutineScope.launch(Dispatchers.IO) {

View File

@@ -662,7 +662,6 @@ fun MainScreen() {
FullscreenControlRoute(
scrcpy = scrcpy,
onBack = rootNavigator.pop,
isInPip = false,
autoExitOnStop = true,
)
}

View File

@@ -2,7 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.content.pm.ActivityInfo
import android.graphics.Rect
import android.util.Rational
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.*
import androidx.lifecycle.Lifecycle
@@ -15,8 +14,6 @@ import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.ui.createThemeController
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -25,51 +22,6 @@ fun StreamScreen(activity: StreamActivity) {
val scrcpy = remember { AppRuntime.scrcpy!! }
val asBundle by Storage.appSettings.bundleState.collectAsState()
val isInPip by activity.pipModeState.collectAsState()
var pipSourceRectHint by remember { mutableStateOf<Rect?>(null) }
var lastPipAspectRatio by remember { mutableStateOf<Rational?>(null) }
var lastPipOrientationLandscape by remember { mutableStateOf<Boolean?>(null) }
DisposableEffect(isInPip) {
onDispose {
VideoOutputTargetState.set(VideoOutputTarget.PREVIEW)
}
}
val currentSession by scrcpy.currentSessionState.collectAsState()
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.configurePip {
setEnabled(true)
setAspectRatio(pipAspectRatio)
setSourceRectHint(pipSourceRectHint)
setSeamlessResizeEnabled(true)
setCloseAction(activity.pipStopAction)
}
}
val themeController = remember(
asBundle.themeBaseIndex,
asBundle.monet,
@@ -99,11 +51,6 @@ fun StreamScreen(activity: StreamActivity) {
FullscreenControlRoute(
scrcpy = scrcpy,
onBack = activity::finish,
isInPip = isInPip,
onVideoBoundsInWindowChanged = {
// 记录下一次进入 PiP 时可用的 sourceRectHint
pipSourceRectHint = it
},
)
}
}
@@ -113,7 +60,6 @@ fun StreamScreen(activity: StreamActivity) {
fun FullscreenControlRoute(
scrcpy: Scrcpy,
onBack: () -> Unit,
isInPip: Boolean = false,
autoExitOnStop: Boolean = false,
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
) {
@@ -153,16 +99,13 @@ fun FullscreenControlRoute(
FullscreenControlScreen(
scrcpy = scrcpy,
onBack = onBack,
isInPip = isInPip,
onVideoSizeChanged = { width, height ->
if (!isInPip) {
activity?.requestedOrientation =
fullscreenRequestedOrientation(
width = width,
height = height,
ignoreSystemRotationLock = asBundle.fullscreenControlIgnoreSystemRotationLock,
)
}
},
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
)

View File

@@ -3,6 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import androidx.annotation.StringRes
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.CoroutineScope
@@ -28,6 +29,7 @@ object AppRuntime {
var scrcpy: Scrcpy? = null
var currentConnectionTarget: ConnectionTarget? = null
var currentConnectedDevice: ConnectedDeviceInfo? = null
var adbLaunchParams: AdbLaunchParams? = null
private val snackbarHostStateLock = Any()
private val snackbarHostStateStack = mutableListOf<SnackbarHostState>()

View File

@@ -0,0 +1,495 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.PixelFormat
import android.graphics.SurfaceTexture
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.view.*
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
/**
* 悬浮窗管理器,替代系统画中画(PiP)。
*
* 使用 WindowManager 创建 TYPE_APPLICATION_OVERLAY 悬浮窗口,
* 包含视频渲染(TextureView)、触摸事件处理和最小化控制按钮。
*
* 生命周期:
* - [show] 当 Activity 进入后台时调用,显示悬浮窗
* - [hide] 当 Activity 回到前台时调用,隐藏悬浮窗
* - [destroy] 彻底清理,释放所有资源
*/
class FloatingVideoWindow(private val context: Context) {
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val metrics = context.resources.displayMetrics
private var windowView: FrameLayout? = null
private var textureView: TextureView? = null
private var controlCircle: View? = null
private var controlPanel: LinearLayout? = null
private var closeBtn: TextView? = null
private var fullscreenBtn: TextView? = null
private var currentSurface: android.view.Surface? = null
private var touchEventHandler: TouchEventHandler? = null
// 触摸状态持有者(独立于 Compose但与 TouchEventHandler 使用相同类型)
private val activePointerIds = LinkedHashSet<Int>()
private val activePointerPositions = LinkedHashMap<Int, Offset>()
private val activePointerDevicePositions = LinkedHashMap<Int, Pair<Int, Int>>()
private val pointerLabels = LinkedHashMap<Int, Int>()
private var nextPointerLabel = 1
private var touchAreaSize = IntSize.Zero
private var isShowing = false
private var controlsVisible = false
private var sessionCollectJob: Job? = null
// 窗口位置
private var windowX: Int = 0
private var windowY: Int = 0
// 拖动相关
private var dragStartX = 0f
private var dragStartY = 0f
private var windowStartX = 0
private var windowStartY = 0
private var isDragging = false
private var onCloseCallback: (() -> Unit)? = null
private var onFullscreenCallback: (() -> Unit)? = null
companion object {
private const val TAG = "FloatingVideoWindow"
private const val CONTROL_AUTO_HIDE_MS = 3000L
}
/**
* 检查悬浮窗权限是否已授予。
*/
fun canDrawOverlays(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Settings.canDrawOverlays(context)
} else {
true
}
}
/**
* 打开悬浮窗权限设置页面。
*/
fun requestOverlayPermission(activity: Activity, requestCode: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
)
activity.startActivityForResult(intent, requestCode)
}
}
/**
* 显示悬浮窗。
* 在 Activity.onStop() 时调用。
*/
fun show(onClose: () -> Unit, onFullscreen: () -> Unit) {
if (isShowing) return
if (!canDrawOverlays()) {
Log.w(TAG, "show(): SYSTEM_ALERT_WINDOW permission not granted")
return
}
onCloseCallback = onClose
onFullscreenCallback = onFullscreen
createWindowView()
setupTouchHandler()
val screenWidth = metrics.widthPixels
val screenHeight = metrics.heightPixels
val smallerDim = minOf(screenWidth, screenHeight)
// 悬浮窗大小:短边的 35%
val windowWidth = (smallerDim * 0.35f).toInt().coerceIn(dp(120), smallerDim / 2)
// 计算初始位置:右上角
if (windowX == 0 && windowY == 0) {
windowX = screenWidth - windowWidth - dp(16)
windowY = dp(80)
}
windowParams.apply {
x = windowX
y = windowY
width = windowWidth
height = ViewGroup.LayoutParams.WRAP_CONTENT
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
}
try {
windowManager.addView(windowView, windowParams)
isShowing = true
Log.i(TAG, "show(): window added at ($windowX, $windowY) size=$windowWidth")
} catch (e: Exception) {
Log.e(TAG, "show(): failed to add window", e)
}
}
/**
* 隐藏悬浮窗。
* 在 Activity.onStart() 时调用。
*/
fun hide() {
if (!isShowing) return
Log.i(TAG, "hide(): removing window")
sessionCollectJob?.cancel()
sessionCollectJob = null
// 保存当前位置
windowView?.let {
windowX = windowParams.x
windowY = windowParams.y
}
// 先分离 Surface
currentSurface?.let { surface ->
scope.launch {
NativeCoreFacade.detachVideoSurface(surface)
}
currentSurface = null
}
try {
windowView?.let { windowManager.removeView(it) }
} catch (e: Exception) {
Log.w(TAG, "hide(): failed to remove view", e)
}
windowView = null
textureView = null
controlCircle = null
controlPanel = null
closeBtn = null
fullscreenBtn = null
touchEventHandler = null
isShowing = false
controlsVisible = false
}
/**
* 彻底销毁悬浮窗并清理资源。
*/
fun destroy() {
hide()
scope.cancel()
onCloseCallback = null
onFullscreenCallback = null
}
private val windowParams = WindowManager.LayoutParams().apply {
type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams.TYPE_PHONE
}
format = PixelFormat.TRANSLUCENT
gravity = Gravity.TOP or Gravity.START
}
@SuppressLint("ClickableViewAccessibility")
private fun createWindowView() {
val root = FrameLayout(context).apply {
clipChildren = false
clipToPadding = false
}
// --- TextureView 用于视频渲染 ---
val tv = TextureView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
)
// 触摸事件:通过 TouchEventHandler 注入到远程设备
setOnTouchListener { _, event ->
touchEventHandler?.handleMotionEvent(event) == true
}
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(
surface: SurfaceTexture,
width: Int,
height: Int,
) {
touchAreaSize = IntSize(width, height)
val s = android.view.Surface(surface)
currentSurface = s
scope.launch {
NativeCoreFacade.attachVideoSurface(s)
}
Log.i(TAG, "surfaceTextureAvailable: ${width}x${height}")
}
override fun onSurfaceTextureSizeChanged(
surface: SurfaceTexture,
width: Int,
height: Int,
) {
touchAreaSize = IntSize(width, height)
// 更新 TouchEventHandler 的触摸区域
val sess = AppRuntime.scrcpy?.currentSessionState?.value
if (sess != null) {
touchEventHandler = buildTouchEventHandler(
sess,
IntSize(width, height),
)
}
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
currentSurface?.let { s ->
scope.launch {
NativeCoreFacade.detachVideoSurface(s)
}
}
currentSurface = null
return true
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {}
}
}
textureView = tv
root.addView(tv)
// --- 圆圈控制按钮 ---
val circleSize = dp(36)
val circleBg = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(0x80000000.toInt())
setStroke(dp(1), 0x80FFFFFF.toInt())
}
val circle = View(context).apply {
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize).apply {
gravity = Gravity.TOP or Gravity.END
topMargin = dp(8)
rightMargin = dp(8)
}
background = circleBg
setOnTouchListener { v, event ->
handleCircleTouch(v, event, root)
}
}
controlCircle = circle
root.addView(circle)
// --- 控制面板(关闭/全屏按钮)---
val panel = LinearLayout(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
).apply {
gravity = Gravity.TOP or Gravity.END
topMargin = dp(8) + circleSize + dp(8)
rightMargin = dp(8)
}
orientation = LinearLayout.VERTICAL
visibility = View.GONE
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8).toFloat()
setColor(0xCC222222.toInt())
}
}
fun createPanelButton(label: String, onClick: () -> Unit): TextView {
return TextView(context).apply {
text = label
textSize = 13f
setTextColor(0xFFFFFFFF.toInt())
gravity = Gravity.CENTER
setPadding(dp(16), dp(10), dp(16), dp(10))
setOnClickListener { onClick() }
}
}
closeBtn = createPanelButton("关闭") {
hide()
onCloseCallback?.invoke()
}
panel.addView(closeBtn)
// 分隔线
panel.addView(View(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(1),
)
setBackgroundColor(0x40FFFFFF.toInt())
})
fullscreenBtn = createPanelButton("全屏") {
hide()
onFullscreenCallback?.invoke()
}
panel.addView(fullscreenBtn)
controlPanel = panel
root.addView(panel)
windowView = root
}
private fun handleCircleTouch(v: View, event: MotionEvent, root: View): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
dragStartX = event.rawX
dragStartY = event.rawY
windowStartX = windowParams.x
windowStartY = windowParams.y
isDragging = false
return true
}
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - dragStartX
val dy = event.rawY - dragStartY
if (kotlin.math.abs(dx) > dp(10).toFloat() || kotlin.math.abs(dy) > dp(10).toFloat()) {
isDragging = true
windowParams.x = (windowStartX + dx).toInt()
windowParams.y = (windowStartY + dy).toInt()
windowView?.let { windowManager.updateViewLayout(it, windowParams) }
}
return true
}
MotionEvent.ACTION_UP -> {
if (!isDragging) {
// 点击:切换控制面板
toggleControls()
}
// 保存位置
windowX = windowParams.x
windowY = windowParams.y
return true
}
}
return false
}
private fun toggleControls() {
val panel = controlPanel ?: return
controlsVisible = !controlsVisible
panel.visibility = if (controlsVisible) View.VISIBLE else View.GONE
if (controlsVisible) {
// 自动隐藏
scope.launch {
delay(CONTROL_AUTO_HIDE_MS)
if (controlsVisible) {
panel.visibility = View.GONE
controlsVisible = false
}
}
}
}
private fun setupTouchHandler() {
val scrcpy = AppRuntime.scrcpy ?: return
val session = scrcpy.currentSessionState.value ?: return
// 监听 session 变化以重建 TouchEventHandler
sessionCollectJob = scope.launch {
scrcpy.currentSessionState.collectLatest { sess ->
if (sess != null) {
touchEventHandler = buildTouchEventHandler(sess, touchAreaSize)
} else {
touchEventHandler = null
// session 结束,关闭悬浮窗
hide()
onCloseCallback?.invoke()
}
}
}
}
/**
* 构建 TouchEventHandler 实例。
* 与 FullscreenControlPage 中的创建方式一致,使用相同的状态持有者类型。
*/
private fun buildTouchEventHandler(
session: Scrcpy.Session.SessionInfo,
areaSize: IntSize,
): TouchEventHandler {
// 清除旧的触摸状态
resetTouchState()
return TouchEventHandler(
coroutineScope = scope,
session = session,
touchAreaSize = areaSize,
activePointerIds = activePointerIds,
activePointerPositions = activePointerPositions,
activePointerDevicePositions = activePointerDevicePositions,
pointerLabels = pointerLabels,
nextPointerLabel = nextPointerLabel,
mouseHoverEnabled = session.mouseHover,
onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons ->
withContext(Dispatchers.IO) {
AppRuntime.scrcpy?.injectTouch(
action = action,
pointerId = pointerId,
x = x,
y = y,
screenWidth = session.width,
screenHeight = session.height,
pressure = pressure,
actionButton = actionButton,
buttons = buttons,
)
}
},
onBackOrScreenOn = { action ->
withContext(Dispatchers.IO) {
AppRuntime.scrcpy?.pressBackOrTurnScreenOn(action)
}
},
onActiveTouchCountChanged = {},
onActiveTouchDebugChanged = {},
onNextPointerLabelChanged = { nextPointerLabel = it },
)
}
private fun resetTouchState() {
activePointerIds.clear()
activePointerPositions.clear()
activePointerDevicePositions.clear()
pointerLabels.clear()
nextPointerLabel = 1
}
private fun dp(value: Int): Int {
return (value * metrics.density + 0.5f).toInt()
}
}

View File

@@ -7,6 +7,7 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.user.home=C\:\\Users\\zhimin.liu\\.gradle
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects

View File

@@ -1,8 +1,8 @@
[versions]
agp = "9.2.1"
bcpkixJdk18on = "1.84"
bcpkixJdk18on = "1.85"
boringssl = "20251124"
conscryptAndroid = "2.5.3"
conscryptAndroid = "2.6.1"
datastorePreferences = "1.2.1"
kotlin = "2.4.0"
kotlinx-serialization = "1.11.0"

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
distributionUrl=https://mirrors.cloud.tencent.com/gradle/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500