@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 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 时自动回退截图轮询。设备画面按右侧栏高度等比缩放,带机身 bezel。
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 reportedSize = false
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 (!reportedSize) {
692
- reportedSize = true
693
- if (onSize) onSize(frame.displayWidth, frame.displayHeight)
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 streamStatus = useStream(CAN_STREAM ? serial : null, visible, canvasRef, setToast, attempt, quality, (w, h) => {
808
- if (w > 0 && h > 0) setRatio(w / h)
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
- api
815
- .screenInfo(serial)
816
- .then((info) => {
817
- if (alive && info && info.width && info.height && info.width > 0 && info.height > 0) {
818
- setDeviceSize({ width: info.width, height: info.height })
819
- setRatio(info.width / info.height)
820
- }
821
- })
822
- .catch(() => {})
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) setSelected(picked.serial)
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-emptypadding' },
1487
- h('div', null, t('empty')),
1488
- h('button', { type: 'button', className: 'dph-link', style: { marginTop: 8 }, onClick: () => setConnectOpen(true) }, '+ ' + t('add')),
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 {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponydev/dsh-phone",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "DSH Web 插件:右侧栏预览并点按控制已连接的 Android 设备(adb)",
5
5
  "keywords": [
6
6
  "dsh",