@ponydev/dsh-phone 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/docs/screenshots/02-preview.png +0 -0
- package/docs/screenshots/03-overview.png +0 -0
- package/lib/adb.js +11 -2
- package/lib/client.js +138 -24
- package/lib/index.js +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1 — 2026-10-25
|
|
4
|
+
|
|
5
|
+
### 修复
|
|
6
|
+
- 云手机在平台侧切换分辨率后画面比例不跟随:预览每 3s 用 `screen-info?fresh` 检测 `wm size`,变化时自动重连流并按新尺寸重启 screenrecord
|
|
7
|
+
- 顶部新增当前流分辨率显示(如 `540×960 · 58fps · 20ms`)
|
|
8
|
+
|
|
9
|
+
## 0.3.0 — 2026-10-24
|
|
10
|
+
|
|
11
|
+
### 新增
|
|
12
|
+
- 打开手机面板自动进入预览:优先本会话上次使用的设备(localStorage 按 sessionId 记忆),否则第一台在线设备
|
|
13
|
+
- 顶部清晰度右侧显示实时 **FPS** 和 **adb 往返延迟**(每 5s 探测一次)
|
|
14
|
+
- 没有可用设备时,居中空态 + 「+ 连接设备」按钮
|
|
15
|
+
|
|
16
|
+
### 变更
|
|
17
|
+
- 「设备列表」仍可从左上角设备下拉进入;自动进入只发生在面板首次挂载
|
|
18
|
+
|
|
3
19
|
## 0.2.2 — 2026-10-24
|
|
4
20
|
|
|
5
21
|
### 修复
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ DSH Web 插件:在右侧栏预览已连接的 Android 设备,点画面就是
|
|
|
15
15
|
|
|
16
16
|
- **会话头部手机图标**:点一下展开右侧栏,角标显示在线设备数。
|
|
17
17
|
- **设备列表**:平铺一列,不按手机类型分组;图标按设备系统显示(Android / HarmonyOS / iOS),旁边两个小标签分别标连接方式(USB / Wi-Fi / Tailscale / emulator)和系统(Android / HarmonyOS / iOS),状态点区分 `device` / `unauthorized` / `offline` / `已保存`。
|
|
18
|
-
- **预览**:H.264 流(`screenrecord` → WebCodecs → canvas),约 20–30fps;不支持 WebCodecs
|
|
18
|
+
- **预览**:H.264 流(`screenrecord` → WebCodecs → canvas),约 20–30fps;不支持 WebCodecs 时自动回退截图轮询。打开面板会自动进入预览(本会话上次设备 → 第一台在线设备)。
|
|
19
19
|
- **点按**:点击 = `tap`,拖拽 = `swipe`(坐标按 `wm size` 的设备像素换算,和流分辨率无关)。
|
|
20
20
|
- **操作栏**:底部是「返回 / 主页 / 多任务 | 截图」,顶部只有设备下拉、旋转、更多。
|
|
21
21
|
- **连接**:`adb connect <ip>:<port>`(无线调试、Tailscale 云手机);连接过的目标自动记到 `$DSH_HOME/phone/targets.json`,未连接时也显示在列表里,点一下就直接连。
|
|
Binary file
|
|
Binary file
|
package/lib/adb.js
CHANGED
|
@@ -285,6 +285,15 @@ export class Adb {
|
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/** 一次最小 adb round-trip,用来估算设备/网络延迟(ms)。 */
|
|
289
|
+
async ping(serial) {
|
|
290
|
+
await this.assertReady(serial)
|
|
291
|
+
const started = process.hrtime.bigint()
|
|
292
|
+
await this._run(['-s', serial, 'shell', 'echo', 'ok'], { timeout: 5000 })
|
|
293
|
+
const ms = Number(process.hrtime.bigint() - started) / 1e6
|
|
294
|
+
return { ms: Math.round(ms), at: Date.now() }
|
|
295
|
+
}
|
|
296
|
+
|
|
288
297
|
/** 系统平台(android / harmony),getprop 读一次缓存 5 分钟。 */
|
|
289
298
|
async platform(serial) {
|
|
290
299
|
const cached = this._platforms.get(serial)
|
|
@@ -300,11 +309,11 @@ export class Adb {
|
|
|
300
309
|
}
|
|
301
310
|
|
|
302
311
|
/** 设备物理分辨率(wm size),客户端用它把画面坐标换算成输入坐标;缓存 60s。 */
|
|
303
|
-
async screenInfo(serial) {
|
|
312
|
+
async screenInfo(serial, options = {}) {
|
|
304
313
|
await this.assertReady(serial)
|
|
305
314
|
const cached = this._screens.get(serial)
|
|
306
315
|
const now = Date.now()
|
|
307
|
-
if (cached && now - cached.at < 60_000) return cached.info
|
|
316
|
+
if (!options.fresh && cached && now - cached.at < 60_000) return cached.info
|
|
308
317
|
const { stdout } = await this._run(['-s', serial, 'shell', 'wm', 'size'], { timeout: 5000 })
|
|
309
318
|
const info = parseWmSize(stdout.toString('utf8'))
|
|
310
319
|
if (!info) throw fail('bad-screen-info', '无法读取设备分辨率')
|
package/lib/client.js
CHANGED
|
@@ -127,6 +127,10 @@ window.__ModuleLoader__.load({
|
|
|
127
127
|
.dph-select .nm{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
128
128
|
.dph-quality{height:28px;padding:0 10px;border:1px solid var(--dsw-alias-border-l1,#e9e9ec);border-radius:999px;background:transparent;color:var(--dsw-alias-label-secondary,#6f6f76);font-size:11.5px;cursor:pointer;flex:none}
|
|
129
129
|
.dph-quality:hover{background:var(--dsw-alias-bg-layer-2,#f4f4f5);color:var(--dsw-alias-label-primary,#17171a)}
|
|
130
|
+
.dph-perf{font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9aa1);white-space:nowrap;margin-left:2px;font-variant-numeric:tabular-nums}
|
|
131
|
+
.dph-empty{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#9a9aa1)}
|
|
132
|
+
.dph-empty-icon{color:var(--dsw-alias-label-tertiary,#9a9aa1);opacity:.7}
|
|
133
|
+
.dph-empty-title{font-size:13px;color:var(--dsw-alias-label-secondary,#6f6f76)}
|
|
130
134
|
.dph-select .chev{color:var(--dsw-alias-label-tertiary,#9a9aa1);flex:none}
|
|
131
135
|
.dph-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
|
|
132
136
|
|
|
@@ -388,7 +392,8 @@ window.__ModuleLoader__.load({
|
|
|
388
392
|
connect: (address) => unwrap('connect', { address }),
|
|
389
393
|
disconnect: (serial) => unwrap('disconnect', { serial }),
|
|
390
394
|
input: (serial, event) => unwrap('input', { serial, event }),
|
|
391
|
-
screenInfo: (serial) => unwrap('screen-info', { serial }),
|
|
395
|
+
screenInfo: (serial, options) => unwrap('screen-info', { serial, fresh: Boolean(options && options.fresh) }),
|
|
396
|
+
ping: (serial) => unwrap('ping', { serial }),
|
|
392
397
|
targets: () => unwrap('targets'),
|
|
393
398
|
removeTarget: (address) => unwrap('targets/remove', { address }),
|
|
394
399
|
shot: async (serial) => {
|
|
@@ -644,7 +649,7 @@ window.__ModuleLoader__.load({
|
|
|
644
649
|
return { codec, description }
|
|
645
650
|
}
|
|
646
651
|
|
|
647
|
-
function useStream(serial, visible, canvasRef, onToast, attempt, quality, onSize) {
|
|
652
|
+
function useStream(serial, visible, canvasRef, onToast, attempt, quality, onSize, onFrame) {
|
|
648
653
|
const [status, setStatus] = useState('idle')
|
|
649
654
|
useEffect(() => {
|
|
650
655
|
if (!serial || !visible || !CAN_STREAM) {
|
|
@@ -658,7 +663,8 @@ window.__ModuleLoader__.load({
|
|
|
658
663
|
let pps = null
|
|
659
664
|
let frames = 0
|
|
660
665
|
let waitingKey = true
|
|
661
|
-
let
|
|
666
|
+
let lastWidth = 0
|
|
667
|
+
let lastHeight = 0
|
|
662
668
|
|
|
663
669
|
const resetDecoder = () => {
|
|
664
670
|
if (decoder) {
|
|
@@ -688,9 +694,11 @@ window.__ModuleLoader__.load({
|
|
|
688
694
|
}
|
|
689
695
|
frames += 1
|
|
690
696
|
waitingKey = false
|
|
691
|
-
if (
|
|
692
|
-
|
|
693
|
-
|
|
697
|
+
if (onFrame) onFrame()
|
|
698
|
+
if (frame.displayWidth !== lastWidth || frame.displayHeight !== lastHeight) {
|
|
699
|
+
lastWidth = frame.displayWidth
|
|
700
|
+
lastHeight = frame.displayHeight
|
|
701
|
+
if (onSize) onSize(lastWidth, lastHeight)
|
|
694
702
|
}
|
|
695
703
|
} finally {
|
|
696
704
|
frame.close()
|
|
@@ -804,27 +812,84 @@ window.__ModuleLoader__.load({
|
|
|
804
812
|
const [loading, setLoading] = useState(true)
|
|
805
813
|
const [attempt, setAttempt] = useState(0)
|
|
806
814
|
const [toast, setToast] = useState(null)
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
815
|
+
const [fps, setFps] = useState(0)
|
|
816
|
+
const [streamSize, setStreamSize] = useState(null)
|
|
817
|
+
const [rtt, setRtt] = useState(null)
|
|
818
|
+
const frameCounter = useRef({ count: 0, at: typeof performance !== 'undefined' ? performance.now() : Date.now() })
|
|
819
|
+
const streamStatus = useStream(
|
|
820
|
+
CAN_STREAM ? serial : null,
|
|
821
|
+
visible,
|
|
822
|
+
canvasRef,
|
|
823
|
+
setToast,
|
|
824
|
+
attempt,
|
|
825
|
+
quality,
|
|
826
|
+
(w, h) => {
|
|
827
|
+
if (w > 0 && h > 0) {
|
|
828
|
+
setRatio(w / h)
|
|
829
|
+
setStreamSize({ width: w, height: h })
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
() => {
|
|
833
|
+
frameCounter.current.count += 1
|
|
834
|
+
},
|
|
835
|
+
)
|
|
810
836
|
|
|
811
837
|
useEffect(() => {
|
|
812
838
|
if (!serial) return undefined
|
|
813
839
|
let alive = true
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
840
|
+
let last = null
|
|
841
|
+
const apply = (info) => {
|
|
842
|
+
if (!info || !info.width || !info.height || info.width < 2 || info.height < 2) return
|
|
843
|
+
const changed = last && (last.width !== info.width || last.height !== info.height)
|
|
844
|
+
last = { width: info.width, height: info.height }
|
|
845
|
+
setDeviceSize(last)
|
|
846
|
+
setRatio(info.width / info.height)
|
|
847
|
+
// 设备分辨率变了(云手机平台切换):重连流,让 Host 按新尺寸重启 screenrecord
|
|
848
|
+
if (changed) setAttempt((value) => value + 1)
|
|
849
|
+
}
|
|
850
|
+
const check = async () => {
|
|
851
|
+
try {
|
|
852
|
+
const info = await api.screenInfo(serial, { fresh: true })
|
|
853
|
+
if (alive) apply(info)
|
|
854
|
+
} catch {}
|
|
855
|
+
}
|
|
856
|
+
check()
|
|
857
|
+
const timer = setInterval(check, 3000)
|
|
823
858
|
return () => {
|
|
824
859
|
alive = false
|
|
860
|
+
clearInterval(timer)
|
|
825
861
|
}
|
|
826
862
|
}, [serial])
|
|
827
863
|
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
const timer = setInterval(() => {
|
|
866
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
|
867
|
+
const state = frameCounter.current
|
|
868
|
+
const seconds = Math.max(0.2, (now - state.at) / 1000)
|
|
869
|
+
setFps(Math.round(state.count / seconds))
|
|
870
|
+
state.count = 0
|
|
871
|
+
state.at = now
|
|
872
|
+
}, 1000)
|
|
873
|
+
return () => clearInterval(timer)
|
|
874
|
+
}, [])
|
|
875
|
+
|
|
876
|
+
useEffect(() => {
|
|
877
|
+
if (!serial || !visible) return undefined
|
|
878
|
+
let alive = true
|
|
879
|
+
const tick = async () => {
|
|
880
|
+
try {
|
|
881
|
+
const value = await api.ping(serial)
|
|
882
|
+
if (alive && value && typeof value.ms === 'number') setRtt(value.ms)
|
|
883
|
+
} catch {}
|
|
884
|
+
}
|
|
885
|
+
tick()
|
|
886
|
+
const timer = setInterval(tick, 5000)
|
|
887
|
+
return () => {
|
|
888
|
+
alive = false
|
|
889
|
+
clearInterval(timer)
|
|
890
|
+
}
|
|
891
|
+
}, [serial, visible])
|
|
892
|
+
|
|
828
893
|
useEffect(() => {
|
|
829
894
|
const el = stageRef.current
|
|
830
895
|
if (!el) return undefined
|
|
@@ -873,6 +938,7 @@ window.__ModuleLoader__.load({
|
|
|
873
938
|
setFrameUrl(url)
|
|
874
939
|
setError(null)
|
|
875
940
|
setLoading(false)
|
|
941
|
+
frameCounter.current.count += 1
|
|
876
942
|
} catch (err) {
|
|
877
943
|
if (!alive) return
|
|
878
944
|
setError(err && err.message ? err.message : 'shot failed')
|
|
@@ -988,10 +1054,13 @@ window.__ModuleLoader__.load({
|
|
|
988
1054
|
ratio,
|
|
989
1055
|
immersive,
|
|
990
1056
|
deviceSize,
|
|
1057
|
+
streamSize,
|
|
991
1058
|
frameUrl,
|
|
992
1059
|
error: errorNow,
|
|
993
1060
|
loading: loadingNow,
|
|
994
1061
|
toast,
|
|
1062
|
+
fps,
|
|
1063
|
+
rtt,
|
|
995
1064
|
setToast,
|
|
996
1065
|
send,
|
|
997
1066
|
screenshot,
|
|
@@ -1003,7 +1072,7 @@ window.__ModuleLoader__.load({
|
|
|
1003
1072
|
}
|
|
1004
1073
|
|
|
1005
1074
|
function PhoneTabBody(props) {
|
|
1006
|
-
const { t, useTabInfo, floatTab } = props
|
|
1075
|
+
const { t, useTabInfo, floatTab, sessionId } = props
|
|
1007
1076
|
const info = typeof useTabInfo === 'function' ? useTabInfo() : null
|
|
1008
1077
|
const tab = info && info.tab ? info.tab : null
|
|
1009
1078
|
const visible = tab ? tab.visible !== false : true
|
|
@@ -1035,6 +1104,7 @@ window.__ModuleLoader__.load({
|
|
|
1035
1104
|
}
|
|
1036
1105
|
})
|
|
1037
1106
|
const [disconnectArmed, setDisconnectArmed] = useState(false)
|
|
1107
|
+
const autoSelected = useRef(false)
|
|
1038
1108
|
const { devices, error, reload } = useDeviceList(visible, nonce)
|
|
1039
1109
|
const quality = selected && QUALITY_ORDER.includes(qualityMap[selected]) ? qualityMap[selected] : 'medium'
|
|
1040
1110
|
const setQualityForSelected = (next) => {
|
|
@@ -1109,14 +1179,46 @@ window.__ModuleLoader__.load({
|
|
|
1109
1179
|
}
|
|
1110
1180
|
}
|
|
1111
1181
|
|
|
1182
|
+
const rememberDevice = (serial) => {
|
|
1183
|
+
try {
|
|
1184
|
+
const raw = JSON.parse(localStorage.getItem('dsh-phone-last-device') || '{}')
|
|
1185
|
+
const map = raw && typeof raw === 'object' ? raw : {}
|
|
1186
|
+
if (sessionId) map[sessionId] = serial
|
|
1187
|
+
map['*'] = serial
|
|
1188
|
+
localStorage.setItem('dsh-phone-last-device', JSON.stringify(map))
|
|
1189
|
+
} catch {}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
useEffect(() => {
|
|
1193
|
+
if (autoSelected.current) return undefined
|
|
1194
|
+
const list = rows
|
|
1195
|
+
if (!list || list.length === 0) return undefined
|
|
1196
|
+
let remembered = null
|
|
1197
|
+
try {
|
|
1198
|
+
const map = JSON.parse(localStorage.getItem('dsh-phone-last-device') || '{}')
|
|
1199
|
+
remembered = (sessionId && map[sessionId]) || map['*'] || null
|
|
1200
|
+
} catch {}
|
|
1201
|
+
const ready = list.filter((item) => item.state === 'device')
|
|
1202
|
+
const pick = list.find((item) => item.serial === remembered) || ready[0] || list[0]
|
|
1203
|
+
if (pick) {
|
|
1204
|
+
autoSelected.current = true
|
|
1205
|
+
setSelected(pick.serial)
|
|
1206
|
+
}
|
|
1207
|
+
return undefined
|
|
1208
|
+
}, [devices, targets, selected, sessionId])
|
|
1209
|
+
|
|
1112
1210
|
const onSelect = (picked) => {
|
|
1113
1211
|
setMenuOpen(false)
|
|
1114
1212
|
if (picked && picked.saved) {
|
|
1115
1213
|
connectTo(picked.serial).then((ok) => {
|
|
1116
|
-
if (ok)
|
|
1214
|
+
if (ok) {
|
|
1215
|
+
rememberDevice(picked.serial)
|
|
1216
|
+
setSelected(picked.serial)
|
|
1217
|
+
}
|
|
1117
1218
|
})
|
|
1118
1219
|
return
|
|
1119
1220
|
}
|
|
1221
|
+
rememberDevice(picked.serial)
|
|
1120
1222
|
setSelected(picked.serial)
|
|
1121
1223
|
}
|
|
1122
1224
|
|
|
@@ -1483,9 +1585,10 @@ window.__ModuleLoader__.load({
|
|
|
1483
1585
|
: rows.length === 0
|
|
1484
1586
|
? h(
|
|
1485
1587
|
'div',
|
|
1486
|
-
{ className: 'dph-
|
|
1487
|
-
h('div',
|
|
1488
|
-
h('
|
|
1588
|
+
{ className: 'dph-empty' },
|
|
1589
|
+
h('div', { className: 'dph-empty-icon' }, h(Icon, { name: 'phone', size: 30 })),
|
|
1590
|
+
h('div', { className: 'dph-empty-title' }, t('empty')),
|
|
1591
|
+
h('button', { type: 'button', className: 'dph-primary', onClick: () => setConnectOpen(true) }, '+ ' + t('add')),
|
|
1489
1592
|
)
|
|
1490
1593
|
: h(DeviceRows, { t, devices: rows, selected, onSelect })
|
|
1491
1594
|
|
|
@@ -1571,6 +1674,16 @@ window.__ModuleLoader__.load({
|
|
|
1571
1674
|
qualityLabel(quality, t),
|
|
1572
1675
|
)
|
|
1573
1676
|
: null,
|
|
1677
|
+
selected
|
|
1678
|
+
? h(
|
|
1679
|
+
'span',
|
|
1680
|
+
{ className: 'dph-perf', title: t('quality') },
|
|
1681
|
+
(preview.streamSize ? preview.streamSize.width + '×' + preview.streamSize.height + ' · ' : '') +
|
|
1682
|
+
preview.fps +
|
|
1683
|
+
'fps · ' +
|
|
1684
|
+
(preview.rtt == null ? '—' : preview.rtt + 'ms'),
|
|
1685
|
+
)
|
|
1686
|
+
: null,
|
|
1574
1687
|
h('div', { className: 'dph-grow' }),
|
|
1575
1688
|
rightButtons,
|
|
1576
1689
|
),
|
|
@@ -1639,7 +1752,8 @@ window.__ModuleLoader__.load({
|
|
|
1639
1752
|
name: 'sidebar.right.pane.tab',
|
|
1640
1753
|
key: 'dsh-phone',
|
|
1641
1754
|
locale: NS,
|
|
1642
|
-
inject: () => ({
|
|
1755
|
+
inject: (sessionId) => ({
|
|
1756
|
+
sessionId,
|
|
1643
1757
|
floatTab: (tabId, rect) => ctx.sidebarRight.float(tabId, rect),
|
|
1644
1758
|
}),
|
|
1645
1759
|
},
|
package/lib/index.js
CHANGED
|
@@ -92,8 +92,10 @@ export function apply(ctx, rawConfig) {
|
|
|
92
92
|
return ok({ targets: await targets.list() })
|
|
93
93
|
case 'targets/remove':
|
|
94
94
|
return ok({ removed: await targets.remove(String(payload.address || '')) })
|
|
95
|
+
case 'ping':
|
|
96
|
+
return ok(await adb.ping(String(payload.serial || '')))
|
|
95
97
|
case 'screen-info':
|
|
96
|
-
return ok(await adb.screenInfo(String(payload.serial || '')))
|
|
98
|
+
return ok(await adb.screenInfo(String(payload.serial || ''), { fresh: payload.fresh === true }))
|
|
97
99
|
case 'disconnect':
|
|
98
100
|
return ok(await adb.disconnect(String(payload.serial || '')))
|
|
99
101
|
case 'input':
|
|
@@ -149,7 +151,7 @@ export function apply(ctx, rawConfig) {
|
|
|
149
151
|
if (preset) {
|
|
150
152
|
openOptions = { bitRate: preset.bitRate }
|
|
151
153
|
try {
|
|
152
|
-
const info = await adb.screenInfo(serial)
|
|
154
|
+
const info = await adb.screenInfo(serial, { fresh: true })
|
|
153
155
|
const dims = streamSizeFor(info, preset.shortSide)
|
|
154
156
|
if (dims) openOptions.size = dims.width + 'x' + dims.height
|
|
155
157
|
} catch {}
|