mirror of
https://github.com/viarotel-org/escrcpy.git
synced 2025-01-31 03:43:46 +01:00
feat: 🚀 增加了对设备交互控制栏的支持
This commit is contained in:
parent
6ab0b1a871
commit
fd207364a4
17
README.md
17
README.md
@ -5,7 +5,7 @@
|
||||
📱 Use Scrcpy with a graphical interface to display and control your Android device, driven by Electron
|
||||
|
||||
<div style="display:flex;">
|
||||
<img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d75ea2c87c734591b2b5c337d8c8b365~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1359&h=693&s=135919&e=jpg&b=ffffff" alt="viarotel-escrcpy" style="width: 100%;">
|
||||
<img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/570065a5683b4cf7af9cfa9743c06ab4~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1360&h=693&s=140693&e=jpg&b=ffffff" alt="viarotel-escrcpy" style="width: 100%;">
|
||||
</div>
|
||||
|
||||
## 特点
|
||||
@ -37,6 +37,8 @@
|
||||
|
||||
### WIFI 连接
|
||||
|
||||
> 注意:如果首次无线连接失败,你可能需要无线配对请参阅 [常见问题](#常见问题)
|
||||
>
|
||||
> 注意:需同时开启无线调试功能,并在无线调试页面中获取你的当前设备的无线地址(通常为你连接WIFI时分配的IP地址)及端口号(默认为 5555)
|
||||
|
||||
1. 同 USB 连接中的 1-2 步骤
|
||||
@ -82,10 +84,11 @@
|
||||
1. 用户界面进行优化,制作合适的 Logo ✅
|
||||
2. 内置的软件更新功能 ✅
|
||||
3. 录制和保存音视频 ✅
|
||||
4. 添加设备交互控制栏 🚧
|
||||
5. 添加 macOS 及 linux 操作系统的支持 🚧
|
||||
6. 支持语言国际化功能 🚧
|
||||
7. 添加对游戏的增强功能 如游戏键位映射 🚧
|
||||
4. 添加设备快捷交互控制栏 ✅
|
||||
5. 支持自定义 Adb 及 Scrcpy 依赖,并支持生成精简版本和完整版本以满足不同用户需求
|
||||
6. 添加 macOS 及 linux 操作系统的支持 🚧
|
||||
7. 支持语言国际化功能 🚧
|
||||
8. 添加对游戏的增强功能,如游戏键位映射 🚧
|
||||
|
||||
## 常见问题
|
||||
|
||||
@ -118,6 +121,10 @@
|
||||
|
||||
请再点一次,或点击刷新设备,一般不会超过两次,如果还不行,请提供机型和安卓版本信息到 [Issues](https://github.com/viarotel-org/escrcpy/issues)
|
||||
|
||||
### 设备交互控制栏为什么不设计为自动跟踪吸附的悬浮菜单?
|
||||
|
||||
采用悬浮菜单方案不可避免地会增加对 Scrcpy 的耦合性,并增加与 Scrcpy 同步更新的难度。许多类似的 ScrcpyGUI 软件在使用此方案后不得不投入大量精力,最终因难以维护而放弃开发。因此,综合考虑,我们决定采用现有的方案,并期待 Scrcpy 未来能够增加原生交互控制栏的支持。
|
||||
|
||||
## 获得帮助
|
||||
|
||||
> 因为是开源项目 全靠爱发电 所以支持有限 更新节奏不固定
|
||||
|
@ -24,6 +24,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"electron-updater": "^6.1.1",
|
||||
"element-plus": "^2.3.14",
|
||||
"fs-extra": "^11.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pinia": "^2.1.6",
|
||||
"ufo": "^1.3.1"
|
||||
|
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -26,6 +26,9 @@ dependencies:
|
||||
element-plus:
|
||||
specifier: ^2.3.14
|
||||
version: 2.3.14(vue@3.3.4)
|
||||
fs-extra:
|
||||
specifier: ^11.1.1
|
||||
version: 11.1.1
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
@ -3276,6 +3279,15 @@ packages:
|
||||
jsonfile: 6.1.0
|
||||
universalify: 2.0.0
|
||||
|
||||
/fs-extra@11.1.1:
|
||||
resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==}
|
||||
engines: {node: '>=14.14'}
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.1.0
|
||||
universalify: 2.0.0
|
||||
dev: false
|
||||
|
||||
/fs-extra@8.1.0:
|
||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import util from 'node:util'
|
||||
import child_process from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import dayjs from 'dayjs'
|
||||
import { Adb } from '@devicefarmer/adbkit'
|
||||
import adbPath from '@resources/core/adb.exe?asset&asarUnpack'
|
||||
|
||||
@ -36,6 +39,38 @@ const getDeviceIP = async (id) => {
|
||||
|
||||
const tcpip = async (id, port = 5555) => await client.getDevice(id).tcpip(port)
|
||||
|
||||
const screencap = async (deviceId, options = {}) => {
|
||||
let fileStream = null
|
||||
try {
|
||||
const device = client.getDevice(deviceId)
|
||||
fileStream = await device.screencap()
|
||||
console.log('fileStream', fileStream)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(error?.message || error)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!fileStream) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fileName = `Screencap-${dayjs().format('YYYY-MM-DD-HH-mm-ss')}.png`
|
||||
const savePath = options.savePath || path.resolve('../', fileName)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fileStream
|
||||
.pipe(fs.createWriteStream(savePath))
|
||||
.on('finish', () => {
|
||||
resolve(true)
|
||||
})
|
||||
.on('error', (error) => {
|
||||
console.warn(error?.message || error)
|
||||
reject(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const watch = async (callback) => {
|
||||
const tracker = await client.trackDevices()
|
||||
tracker.on('add', async (ret) => {
|
||||
@ -71,8 +106,9 @@ export default () => {
|
||||
kill,
|
||||
connect,
|
||||
disconnect,
|
||||
watch,
|
||||
getDeviceIP,
|
||||
tcpip,
|
||||
screencap,
|
||||
watch,
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<el-icon class="is-loading">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'LoadingIcon',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
130
src/renderer/src/components/Devices/ControlBar/index.vue
Normal file
130
src/renderer/src/components/Devices/ControlBar/index.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="bg-primary-100 -my-[8px]">
|
||||
<el-button
|
||||
v-for="(item, index) in controlModel"
|
||||
:key="index"
|
||||
type="primary"
|
||||
plain
|
||||
class="!border-none !mx-0 bg-transparent !rounded-0"
|
||||
:icon="item.icon"
|
||||
:disabled="device.$unauthorized"
|
||||
:title="item.tips"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dayjs from 'dayjs'
|
||||
import LoadingIcon from './LoadingIcon/index.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
device: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
controlModel: [
|
||||
{
|
||||
label: '切换键',
|
||||
icon: 'Switch',
|
||||
command: 'input keyevent KEYCODE_APP_SWITCH',
|
||||
},
|
||||
{
|
||||
label: '主屏幕键',
|
||||
icon: 'HomeFilled',
|
||||
command: 'input keyevent KEYCODE_HOME',
|
||||
},
|
||||
{
|
||||
label: '返回键',
|
||||
icon: 'Back',
|
||||
command: 'input keyevent KEYCODE_BACK',
|
||||
},
|
||||
{
|
||||
label: '菜单键',
|
||||
icon: 'Menu',
|
||||
command: 'input keyevent KEYCODE_MENU',
|
||||
tips: '不要和切换键搞错啦',
|
||||
},
|
||||
{
|
||||
label: '电源键',
|
||||
icon: 'SwitchButton',
|
||||
command: 'input keyevent KEYCODE_POWER',
|
||||
tips: '可以用来开启或关闭屏幕',
|
||||
},
|
||||
{
|
||||
label: '截屏快照',
|
||||
icon: 'Crop',
|
||||
handle: this.handleScreenCap,
|
||||
tips: '不要和切换键搞错啦',
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
scrcpyConfig() {
|
||||
return this.$store.scrcpy.config
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick(row) {
|
||||
if (row.command) {
|
||||
this.$adb.deviceShell(this.device.id, row.command)
|
||||
}
|
||||
else if (row.handle) {
|
||||
row.handle(this.device)
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
async handleScreenCap(device) {
|
||||
const deviceName = device.name || device.id
|
||||
|
||||
const messageEl = this.$message({
|
||||
message: ` 正在截取 ${deviceName} 的屏幕快照...`,
|
||||
icon: LoadingIcon,
|
||||
duration: 0,
|
||||
})
|
||||
|
||||
const fileName = `${deviceName}-screencap-${dayjs().format('YYYY-MM-DD-HH-mm-ss')}.png`
|
||||
const savePath = this.$path.resolve(this.scrcpyConfig['--record'], fileName)
|
||||
|
||||
try {
|
||||
await this.$adb.screencap(device.id, { savePath })
|
||||
this.handleScreencapSuccess(savePath)
|
||||
}
|
||||
catch (error) {
|
||||
if (error.message) {
|
||||
this.$message.warning(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
messageEl.close()
|
||||
},
|
||||
async handleScreencapSuccess(savePath) {
|
||||
try {
|
||||
await this.$confirm('是否前往截屏位置进行查看?', '录制成功', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
closeOnClickModal: false,
|
||||
type: 'success',
|
||||
})
|
||||
this.$electron.ipcRenderer.invoke('show-item-in-folder', savePath)
|
||||
}
|
||||
catch (error) {
|
||||
if (error.message) {
|
||||
this.$message.warning(error.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
@ -41,12 +41,14 @@
|
||||
</div>
|
||||
<div class="pt-4 flex-1 h-0 overflow-hidden">
|
||||
<el-table
|
||||
ref="elTable"
|
||||
v-loading="loading"
|
||||
:element-loading-text="loadingText"
|
||||
:data="deviceList"
|
||||
style="width: 100%"
|
||||
border
|
||||
height="100%"
|
||||
row-key="id"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty description="设备列表为空" />
|
||||
@ -97,16 +99,6 @@
|
||||
{{ row.$recordLoading ? '录制中' : '开始录制' }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
text
|
||||
icon="SwitchButton"
|
||||
:disabled="row.$unauthorized"
|
||||
@click="handleScreenUp(row)"
|
||||
>
|
||||
点亮屏幕
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="!row.$wireless"
|
||||
type="primary"
|
||||
@ -131,6 +123,16 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column type="expand">
|
||||
<template #header>
|
||||
<el-icon class="" title="设备交互">
|
||||
<Operation class="" />
|
||||
</el-icon>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<ControlBar :device="row" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<PairDialog ref="pairDialog" @success="onPairSuccess" />
|
||||
@ -142,10 +144,12 @@ import { isIPWithPort, sleep } from '@renderer/utils/index.js'
|
||||
import storage from '@renderer/utils/storages'
|
||||
import dayjs from 'dayjs'
|
||||
import PairDialog from './PairDialog/index.vue'
|
||||
import ControlBar from './ControlBar/index.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PairDialog,
|
||||
ControlBar,
|
||||
},
|
||||
data() {
|
||||
const adbCache = storage.get('adbCache') || {}
|
||||
@ -185,12 +189,13 @@ export default {
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
toggleRowExpansion(...params) {
|
||||
this.$refs.elTable.toggleRowExpansion(...params)
|
||||
},
|
||||
getRecordPath(row) {
|
||||
const defaultPath = this.$path.resolve('../')
|
||||
// console.log('defaultPath', defaultPath)
|
||||
const basePath = this.scrcpyConfig['--record'] || defaultPath
|
||||
const recordFormat = this.scrcpyConfig['--record-format'] || 'mp4'
|
||||
const fileName = `${row.name || row.id}-${dayjs().format(
|
||||
const basePath = this.scrcpyConfig['--record']
|
||||
const recordFormat = this.scrcpyConfig['--record-format']
|
||||
const fileName = `${row.name || row.id}-recording-${dayjs().format(
|
||||
'YYYY-MM-DD-HH-mm-ss',
|
||||
)}.${recordFormat}`
|
||||
const joinValue = this.$path.join(basePath, fileName)
|
||||
@ -199,9 +204,12 @@ export default {
|
||||
},
|
||||
async handleRecord(row) {
|
||||
row.$recordLoading = true
|
||||
const recordPath = this.getRecordPath(row)
|
||||
|
||||
this.toggleRowExpansion(row, true)
|
||||
|
||||
const savePath = this.getRecordPath(row)
|
||||
try {
|
||||
const command = `--serial=${row.id} --window-title=${row.name}-${row.id}-🎥录制中... --record=${recordPath} ${this.stringScrcpyConfig}`
|
||||
const command = `--serial=${row.id} --window-title=${row.name}-${row.id}-🎥录制中... --record=${savePath} ${this.stringScrcpyConfig}`
|
||||
|
||||
console.log('handleRecord.command', command)
|
||||
|
||||
@ -214,7 +222,7 @@ export default {
|
||||
type: 'success',
|
||||
})
|
||||
|
||||
this.$electron.ipcRenderer.invoke('show-item-in-folder', recordPath)
|
||||
this.$electron.ipcRenderer.invoke('show-item-in-folder', savePath)
|
||||
}
|
||||
catch (error) {
|
||||
if (error.message) {
|
||||
@ -225,6 +233,9 @@ export default {
|
||||
},
|
||||
async handleMirror(row) {
|
||||
row.$loading = true
|
||||
|
||||
this.toggleRowExpansion(row, true)
|
||||
|
||||
try {
|
||||
await this.$scrcpy.shell(
|
||||
`--serial=${row.id} --window-title=${row.name}-${row.id} ${this.stringScrcpyConfig}`,
|
||||
@ -255,9 +266,6 @@ export default {
|
||||
onPairSuccess() {
|
||||
this.handleConnect()
|
||||
},
|
||||
handleScreenUp(row) {
|
||||
this.$adb.deviceShell(row.id, 'input keyevent KEYCODE_POWER')
|
||||
},
|
||||
handleReset() {
|
||||
this.$electron.ipcRenderer.send('restart-app')
|
||||
},
|
||||
@ -324,15 +332,17 @@ export default {
|
||||
await sleep()
|
||||
try {
|
||||
const data = await this.$adb.getDevices()
|
||||
this.deviceList = (data || []).map(item => ({
|
||||
...item,
|
||||
name: item.model ? item.model.split(':')[1] : '未授权设备',
|
||||
$loading: false,
|
||||
$recordLoading: false,
|
||||
$stopLoading: false,
|
||||
$unauthorized: item.type === 'unauthorized',
|
||||
$wireless: isIPWithPort(item.id),
|
||||
}))
|
||||
this.deviceList
|
||||
= data?.map(item => ({
|
||||
...item,
|
||||
id: item.id,
|
||||
name: item.model ? item.model.split(':')[1] : '未授权设备',
|
||||
$loading: false,
|
||||
$recordLoading: false,
|
||||
$stopLoading: false,
|
||||
$unauthorized: item.type === 'unauthorized',
|
||||
$wireless: isIPWithPort(item.id),
|
||||
})) || []
|
||||
|
||||
console.log('getDeviceData.data', this.deviceList)
|
||||
}
|
||||
|
@ -1,14 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import storage from '@renderer/utils/storages'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import * as model from './model/index.js'
|
||||
import { pickBy } from 'lodash-es'
|
||||
import * as scrcpyModel from './model/index.js'
|
||||
|
||||
/**
|
||||
* 获取 Scrcpy 默认配置
|
||||
*/
|
||||
function getDefaultConfig(type) {
|
||||
const model = []
|
||||
if (type) {
|
||||
const handler = scrcpyModel[type]
|
||||
model.push(...handler())
|
||||
}
|
||||
else {
|
||||
// console.log('scrcpyModel', scrcpyModel)
|
||||
const values = Object.values(scrcpyModel)
|
||||
model.push(...values.flatMap(handler => handler()))
|
||||
}
|
||||
|
||||
const value = model.reduce((obj, item) => {
|
||||
const { field, value } = item
|
||||
obj[field] = value
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const useScrcpyStore = defineStore({
|
||||
id: 'app-scrcpy',
|
||||
state() {
|
||||
return {
|
||||
model,
|
||||
config: storage.get('scrcpyConfig'),
|
||||
model: scrcpyModel,
|
||||
defaultConfig: getDefaultConfig(),
|
||||
config: {},
|
||||
excludeKeys: ['--record', '--record-format'],
|
||||
}
|
||||
},
|
||||
@ -44,37 +69,27 @@ export const useScrcpyStore = defineStore({
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
getDefaultConfig,
|
||||
init() {
|
||||
this.config = this.config || this.getDefaultConfig()
|
||||
this.config = {
|
||||
...this.defaultConfig,
|
||||
...(storage.get('scrcpyConfig') || {}),
|
||||
}
|
||||
|
||||
return this.config
|
||||
},
|
||||
updateConfig(value) {
|
||||
this.config = cloneDeep(value)
|
||||
storage.set('scrcpyConfig', this.config)
|
||||
updateConfig(data) {
|
||||
const pickConfig = pickBy(data, value => !!value)
|
||||
|
||||
console.log('pickConfig', pickConfig)
|
||||
|
||||
storage.set('scrcpyConfig', pickConfig)
|
||||
|
||||
this.init()
|
||||
},
|
||||
getModel(key, params) {
|
||||
const handler = this.model[key]
|
||||
const handler = scrcpyModel[key]
|
||||
return handler(params)
|
||||
},
|
||||
getDefaultConfig(type) {
|
||||
const model = []
|
||||
if (type) {
|
||||
const handler = this.model[type]
|
||||
model.push(...handler())
|
||||
}
|
||||
else {
|
||||
// console.log('scrcpyModel', scrcpyModel)
|
||||
const values = Object.values(this.model)
|
||||
model.push(...values.flatMap(handler => handler()))
|
||||
}
|
||||
|
||||
const value = model.reduce((obj, item) => {
|
||||
const { field, value } = item
|
||||
obj[field] = value
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
return value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -1,10 +1,12 @@
|
||||
export default () => {
|
||||
const $path = window.nodePath
|
||||
|
||||
return [
|
||||
{
|
||||
label: '录制存储路径',
|
||||
type: 'input.directory',
|
||||
field: '--record',
|
||||
value: '',
|
||||
value: $path.resolve('../'),
|
||||
placeholder: '默认值为执行应用的同级目录',
|
||||
},
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user