feat: 🚀 增加了对设备交互控制栏的支持

This commit is contained in:
viarotel 2023-10-13 17:01:59 +08:00
parent 6ab0b1a871
commit fd207364a4
9 changed files with 292 additions and 66 deletions

View File

@ -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 未来能够增加原生交互控制栏的支持。
## 获得帮助
> 因为是开源项目 全靠爱发电 所以支持有限 更新节奏不固定

View File

@ -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
View File

@ -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'}

View File

@ -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,
}
}

View File

@ -0,0 +1,13 @@
<template>
<el-icon class="is-loading">
<Loading />
</el-icon>
</template>
<script>
export default {
name: 'LoadingIcon',
}
</script>
<style></style>

View 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>

View File

@ -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)
}

View File

@ -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
},
},
})

View File

@ -1,10 +1,12 @@
export default () => {
const $path = window.nodePath
return [
{
label: '录制存储路径',
type: 'input.directory',
field: '--record',
value: '',
value: $path.resolve('../'),
placeholder: '默认值为执行应用的同级目录',
},
{