@ponydev/dsh-phone 0.0.0-stage → 0.2.0

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/lib/client.js ADDED
@@ -0,0 +1,1652 @@
1
+ // dsh-phone · Client 半区(手写 ModuleLoader bundle,无构建步骤)
2
+ // - 右侧栏 tab「手机」:设备列表 / 设备画面
3
+ // - 会话头部手机图标:点一下展开右侧栏
4
+ // - 画面:轮询 /phone/shot(PNG),点击 = tap,拖拽 = swipe
5
+ // - 没有分辨率/网速元数据栏,没有文字输入框
6
+ window.__ModuleLoader__.load({
7
+ id: 'dsh-phone',
8
+ factory(require) {
9
+ const React = require('react')
10
+ const h = React.createElement
11
+ const { useState, useEffect, useRef, useCallback } = React
12
+
13
+ const NS = 'dsh-phone'
14
+
15
+ // ---------------------------------------------------------------- i18n
16
+ const zh = {
17
+ title: '手机',
18
+ desc: '预览并点按已连接的 Android 设备',
19
+ devices: '设备',
20
+ connected: '已连接',
21
+ cloud: '云手机',
22
+ select: '选择设备',
23
+ refresh: '刷新',
24
+ add: '连接设备',
25
+ address: '地址(ip:port)',
26
+ cancel: '取消',
27
+ connect: '连接',
28
+ connecting: '连接中…',
29
+ empty: '没有已连接的设备',
30
+ noAdb: '未找到 adb:请安装 Android platform-tools,或在 cordis.patch.yml 里设置 adbPath',
31
+ waitingHost: '正在等待宿主接口 /phone… 重启 dsh web 后可用',
32
+ retry: '重试',
33
+ loading: '载入中…',
34
+ shotFailed: '画面获取失败',
35
+ unauthorized: '未授权',
36
+ offline: '离线',
37
+ back: '返回',
38
+ home: '主页',
39
+ recents: '多任务',
40
+ screenshot: '截图',
41
+ more: '更多',
42
+ power: '电源',
43
+ rotate: '旋转',
44
+ reconnect: '重新连接',
45
+ disconnect: '断开连接',
46
+ copySerial: '复制序列号',
47
+ copied: '已复制',
48
+ saved: '截图已保存',
49
+ listHint: '点设备预览画面',
50
+ float: '浮窗打开',
51
+ saved: '已保存',
52
+ diagnostics: 'adb 诊断',
53
+ quality: '显示清晰度',
54
+ immersiveOn: '沉浸模式',
55
+ immersiveOff: '显示操作栏',
56
+ qualityLow: '流畅',
57
+ qualityMedium: '标准',
58
+ qualityHigh: '高清',
59
+ confirmDisconnect: '确认断开?',
60
+ noSaved: '还没有保存的连接目标',
61
+ copyDiag: '复制诊断信息',
62
+ remove: '移除',
63
+ diagHint: '连接失败时先看这里:未授权要去手机上点允许,offline 要重新 connect。',
64
+ unauthorizedHint: '在手机上点「允许 USB 调试」后重试',
65
+ }
66
+ const en = {
67
+ title: 'Phone',
68
+ desc: 'Preview and tap a connected Android device',
69
+ devices: 'Devices',
70
+ connected: 'Connected',
71
+ cloud: 'Cloud phones',
72
+ select: 'Select device',
73
+ refresh: 'Refresh',
74
+ add: 'Connect device',
75
+ address: 'Address (ip:port)',
76
+ cancel: 'Cancel',
77
+ connect: 'Connect',
78
+ connecting: 'Connecting…',
79
+ empty: 'No connected devices',
80
+ noAdb: 'adb not found. Install Android platform-tools or set adbPath in cordis.patch.yml',
81
+ waitingHost: 'Waiting for the host API /phone… restart dsh web',
82
+ retry: 'Retry',
83
+ loading: 'Loading…',
84
+ shotFailed: 'Could not fetch the screen',
85
+ unauthorized: 'Unauthorized',
86
+ offline: 'Offline',
87
+ back: 'Back',
88
+ home: 'Home',
89
+ recents: 'Recents',
90
+ screenshot: 'Screenshot',
91
+ more: 'More',
92
+ power: 'Power',
93
+ rotate: 'Rotate',
94
+ reconnect: 'Reconnect',
95
+ disconnect: 'Disconnect',
96
+ copySerial: 'Copy serial',
97
+ copied: 'Copied',
98
+ saved: 'Screenshot saved',
99
+ listHint: 'Pick a device to preview',
100
+ float: 'Open as floating window',
101
+ saved: 'Saved',
102
+ diagnostics: 'adb diagnostics',
103
+ quality: 'Stream quality',
104
+ immersiveOn: 'Immersive mode',
105
+ immersiveOff: 'Show controls',
106
+ qualityLow: 'Low',
107
+ qualityMedium: 'Medium',
108
+ qualityHigh: 'High',
109
+ confirmDisconnect: 'Confirm disconnect?',
110
+ noSaved: 'No saved targets yet',
111
+ copyDiag: 'Copy diagnostics',
112
+ remove: 'Remove',
113
+ diagHint: 'If connecting fails: authorize USB debugging on the phone; reconnect offline devices.',
114
+ unauthorizedHint: 'Allow USB debugging on the phone, then retry',
115
+ }
116
+
117
+ // ---------------------------------------------------------------- styles
118
+ const CSS = `
119
+ .dph-root{position:relative;display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#17171a);font-size:13px;line-height:1.45}
120
+ .dph-bar{height:42px;flex:none;display:flex;align-items:center;gap:2px;padding:0 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#e9e9ec)}
121
+ .dph-grow{flex:1 1 auto}
122
+ .dph-ibtn{width:28px;height:28px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#6f6f76);display:inline-grid;place-items:center;cursor:pointer;padding:0}
123
+ .dph-ibtn:hover{background:var(--dsw-alias-bg-layer-2,#f4f4f5);color:var(--dsw-alias-label-primary,#17171a)}
124
+ .dph-ibtn:active{transform:scale(.94)}
125
+ .dph-select{height:28px;max-width:230px;display:flex;align-items:center;gap:7px;padding:0 8px;border:0;border-radius:8px;background:var(--dsw-alias-bg-layer-2,#f4f4f5);color:var(--dsw-alias-label-primary,#17171a);font-size:12.5px;cursor:pointer}
126
+ .dph-select:hover{background:var(--dsw-alias-bg-layer-2,#ececef)}
127
+ .dph-select .nm{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
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
+ .dph-quality:hover{background:var(--dsw-alias-bg-layer-2,#f4f4f5);color:var(--dsw-alias-label-primary,#17171a)}
130
+ .dph-select .chev{color:var(--dsw-alias-label-tertiary,#9a9aa1);flex:none}
131
+ .dph-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
132
+
133
+ .dph-list{flex:1 1 auto;min-height:0;padding:6px 10px 14px;overflow:auto}
134
+ .dph-group{padding:12px 10px 5px;font-size:11px;letter-spacing:.4px;color:var(--dsw-alias-label-tertiary,#9a9aa1);display:flex;align-items:center;gap:8px}
135
+ .dph-group:after{content:"";flex:1;height:1px;background:var(--dsw-alias-border-l1,#e9e9ec)}
136
+ .dph-row{display:flex;align-items:center;gap:12px;width:100%;padding:9px 10px;border:0;border-radius:10px;background:transparent;text-align:left;cursor:pointer;color:inherit;font:inherit}
137
+ .dph-row:hover{background:var(--dsw-alias-bg-layer-2,#f4f4f5)}
138
+ .dph-row.sel{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#3b6cff) 8%,transparent)}
139
+ .dph-av{width:32px;height:32px;border-radius:9px;background:transparent;display:grid;place-items:center;color:var(--dsw-alias-label-secondary,#6f6f76);flex:none;position:relative}
140
+ .dph-av .on{position:absolute;right:-1px;bottom:-1px;width:9px;height:9px;border-radius:50%;border:2px solid var(--dsw-alias-bg-layer-1,#fff);background:var(--dsw-alias-state-success-primary,#22c55e)}
141
+ .dph-av .warn{background:var(--dsw-alias-state-warn-primary,#f59e0b)}
142
+ .dph-av .off{background:var(--dsw-alias-label-tertiary,#9a9aa1)}
143
+ .dph-meta{display:block;min-width:0;flex:1}
144
+ .dph-model{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px}
145
+ .dph-tag{font-size:10px;font-weight:500;color:var(--dsw-alias-label-secondary,#6f6f76);background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l1,#e9e9ec);border-radius:5px;padding:1px 5px}
146
+ .dph-tag-os{color:var(--dsw-alias-label-tertiary,#9a9aa1);background:transparent}
147
+ .dph-serial{display:block;font:11px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-tertiary,#9a9aa1);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
148
+ .dph-state{font-size:11px;color:var(--dsw-alias-state-warn-primary,#b45309);flex:none}
149
+ .dph-state.saved{color:var(--dsw-alias-label-tertiary,#9a9aa1)}
150
+ .dph-chev{color:var(--dsw-alias-label-tertiary,#9a9aa1);flex:none}
151
+ .dph-emptypadding{padding:26px 20px;color:var(--dsw-alias-label-tertiary,#9a9aa1);font-size:12.5px;line-height:1.7}
152
+ .dph-link{color:var(--dsw-alias-brand-primary,#3b6cff);cursor:pointer;background:none;border:0;padding:0;font:inherit}
153
+
154
+ .dph-stage.dph-stage-imm{align-items:center;padding:14px 12px}
155
+ .dph-navbar{position:absolute;z-index:6;left:50%;bottom:18px;transform:translateX(-50%);display:flex;align-items:center;gap:2px;padding:3px 14px;border-radius:999px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-primary,#17171a) 9%,transparent);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 86%,transparent);-webkit-backdrop-filter:blur(20px) saturate(1.6);backdrop-filter:blur(20px) saturate(1.6);box-shadow:0 14px 34px rgba(0,0,0,.18),inset 0 1px 0 color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 65%,transparent)}
156
+ .dph-navbtn{width:36px;height:36px;border:0;border-radius:999px;background:transparent;color:color-mix(in srgb,var(--dsw-alias-label-primary,#17171a) 74%,transparent);display:inline-grid;place-items:center;cursor:pointer;padding:0;transition:background .15s ease,transform .1s ease}
157
+ .dph-navbtn:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary,#17171a) 8%,transparent);color:var(--dsw-alias-label-primary,#17171a)}
158
+ .dph-navbtn:active{transform:scale(.94)}
159
+ .dph-navsep{width:1px;height:14px;background:color-mix(in srgb,var(--dsw-alias-label-primary,#17171a) 12%,transparent);margin:0 7px}
160
+ .dph-danger{color:var(--dsw-alias-label-secondary,#6f6f76)}
161
+ .dph-danger.armed{color:var(--dsw-alias-state-error-primary,#dc2626);font-weight:600}
162
+ .dph-stage{flex:1 1 auto;min-height:0;background:var(--dsw-alias-bg-layer-2,#f2f2f4);display:flex;align-items:flex-start;justify-content:center;position:relative;overflow:hidden;padding:10px 12px}
163
+ .dph-phone{position:relative;border-radius:42px;background:#0b0b0d;padding:9px;box-sizing:border-box;box-shadow:inset 0 0 0 1.5px #2c2c31,inset 0 0 0 4px #050506,0 24px 48px rgba(0,0,0,.24)}
164
+ .dph-phone:before{content:"";position:absolute;left:-3px;top:118px;width:3px;height:84px;border-radius:2px;background:#232327}
165
+ .dph-phone:after{content:"";position:absolute;right:-3px;top:132px;width:3px;height:54px;border-radius:2px;background:#232327}
166
+ .dph-screen{position:relative;width:100%;height:100%;border-radius:34px;overflow:hidden;background:#101014}
167
+ .dph-canvas{display:block;width:100%;height:100%;touch-action:none;cursor:pointer;background:#000}
168
+ .dph-screen img{display:block;width:100%;height:100%;object-fit:fill;user-select:none;-webkit-user-drag:none;touch-action:none;cursor:pointer}
169
+ .dph-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:rgba(16,16,20,.72);color:#fff;font-size:12px;text-align:center;padding:18px}
170
+ .dph-overlay .msg{max-width:220px;line-height:1.6;opacity:.92}
171
+ .dph-btn{height:28px;padding:0 12px;border-radius:8px;border:1px solid rgba(255,255,255,.24);background:rgba(255,255,255,.12);color:#fff;cursor:pointer;font-size:12px}
172
+ .dph-btn:hover{background:rgba(255,255,255,.2)}
173
+ .dph-spin{width:20px;height:20px;border-radius:50%;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;animation:dphspin .9s linear infinite}
174
+ @keyframes dphspin{to{transform:rotate(360deg)}}
175
+
176
+ .dph-backdrop{position:absolute;inset:0;z-index:20;background:transparent}
177
+ .dph-menu{position:absolute;z-index:21;top:40px;left:8px;width:330px;max-height:calc(100% - 56px);overflow:auto;padding:6px;border:1px solid var(--dsw-alias-border-l1,#e3e4e8);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 16px 40px rgba(0,0,0,.16)}
178
+ .dph-menu .dph-row{padding:8px}
179
+ .dph-menu-foot{border-top:1px solid var(--dsw-alias-border-l1,#eee);margin-top:4px;padding:6px 8px 2px;display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--dsw-alias-label-tertiary,#9a9aa1)}
180
+ .dph-connect{position:absolute;z-index:21;top:48px;left:8px;right:8px;max-width:340px;padding:14px;border:1px solid var(--dsw-alias-border-l1,#e3e4e8);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 16px 40px rgba(0,0,0,.16)}
181
+ .dph-connect .ttl{font-size:13px;font-weight:600;margin-bottom:2px}
182
+ .dph-connect .sub{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#9a9aa1);margin-bottom:10px}
183
+ .dph-field{display:flex;gap:8px}
184
+ .dph-input{flex:1;height:32px;border:1px solid var(--dsw-alias-border-l2,#dededf);border-radius:8px;padding:0 10px;font:11.5px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary,#17171a);background:var(--dsw-alias-bg-layer-1,#fff);outline:none}
185
+ .dph-input:focus{border-color:var(--dsw-alias-brand-primary,#3b6cff)}
186
+ .dph-primary{height:32px;padding:0 14px;border:0;border-radius:8px;background:var(--dsw-alias-label-primary,#17171a);color:var(--dsw-alias-bg-layer-1,#fff);font-size:12px;cursor:pointer}
187
+ .dph-primary:disabled{opacity:.5;cursor:default}
188
+ .dph-err{color:var(--dsw-alias-state-error-primary,#dc2626);font-size:11.5px;margin-top:8px;word-break:break-word}
189
+ .dph-toast{position:absolute;z-index:30;left:50%;bottom:78px;transform:translateX(-50%);max-width:80%;padding:6px 12px;border-radius:8px;background:rgba(23,23,26,.88);color:#fff;font-size:11.5px;pointer-events:none}
190
+ .dph-menu-list{display:block;max-height:320px;overflow:auto}
191
+ .dph-saved{margin-top:12px;border-top:1px solid var(--dsw-alias-border-l1,#eee);padding-top:10px}
192
+ .dph-saved-title{font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9aa1);letter-spacing:.3px;margin-bottom:6px}
193
+ .dph-saved-row{display:flex;align-items:center;gap:10px;padding:5px 2px}
194
+ .dph-saved-addr{font:11px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary,#6f6f76);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
195
+ .dph-saved-empty{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#9a9aa1);line-height:1.6;margin-bottom:10px}
196
+ .dph-link-muted{color:var(--dsw-alias-label-tertiary,#9a9aa1)}
197
+ .dph-diag{position:absolute;z-index:21;top:48px;left:8px;right:8px;max-width:420px;padding:14px;border:1px solid var(--dsw-alias-border-l1,#e3e4e8);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 16px 40px rgba(0,0,0,.16)}
198
+ .dph-diag .ttl{font-size:13px;font-weight:600;margin-bottom:8px}
199
+ .dph-diag-pre{margin:0 0 10px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2,#f4f4f5);font:11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary,#4b4b52);white-space:pre-wrap;word-break:break-word;max-height:220px;overflow:auto}
200
+ .dph-ghost{height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,#dededf);border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#6f6f76);font-size:12px;cursor:pointer}
201
+
202
+ .dph-headbtn{position:relative;width:30px;height:30px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#6f6f76);display:inline-grid;place-items:center;cursor:pointer;padding:0}
203
+ .dph-headbtn:hover{background:var(--dsw-alias-bg-layer-2,#f4f4f5);color:var(--dsw-alias-label-primary,#17171a)}
204
+ .dph-headbtn.active{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#3b6cff) 10%,transparent);color:var(--dsw-alias-brand-primary,#3b6cff)}
205
+ .dph-count{position:absolute;top:2px;right:2px;min-width:13px;height:13px;padding:0 3px;border-radius:7px;background:var(--dsw-alias-brand-primary,#3b6cff);color:#fff;font-size:9px;font-weight:600;display:grid;place-items:center}
206
+ `
207
+
208
+ // ---------------------------------------------------------------- icons
209
+ const SVG_PROPS = {
210
+ width: 15,
211
+ height: 15,
212
+ viewBox: '0 0 24 24',
213
+ fill: 'none',
214
+ stroke: 'currentColor',
215
+ strokeWidth: 1.9,
216
+ strokeLinecap: 'round',
217
+ strokeLinejoin: 'round',
218
+ 'aria-hidden': 'true',
219
+ }
220
+
221
+ function Icon(props) {
222
+ const name = props.name
223
+ const size = props.size || 15
224
+ const p = { ...SVG_PROPS, width: size, height: size, className: props.className }
225
+ switch (name) {
226
+ case 'phone':
227
+ return h('svg', p, h('rect', { x: 6.5, y: 2.5, width: 11, height: 19, rx: 2.6 }), h('path', { d: 'M10.5 18.6h3' }))
228
+ case 'android':
229
+ return h(
230
+ 'svg',
231
+ { ...p, fill: 'currentColor', stroke: 'none' },
232
+ h('path', {
233
+ fillRule: 'evenodd',
234
+ d: 'M6 18.2v-6.8a6 6 0 0 1 12 0v6.8H6Zm3.3-6.3a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4Zm5.4 0a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4Z',
235
+ }),
236
+ h('path', {
237
+ d: 'M8.6 6.4 7 3.9M15.4 6.4 17 3.9',
238
+ fill: 'none',
239
+ stroke: 'currentColor',
240
+ strokeWidth: 2,
241
+ strokeLinecap: 'round',
242
+ }),
243
+ )
244
+ case 'harmony':
245
+ return h(
246
+ 'svg',
247
+ { ...p, fill: 'currentColor', stroke: 'none' },
248
+ h('path', { d: 'M7 4.8h2.6v5.7h4.8V4.8H17v14.4h-2.6v-6.2H9.6v6.2H7z' }),
249
+ )
250
+ case 'apple':
251
+ return h(
252
+ 'svg',
253
+ { ...p, fill: 'currentColor', stroke: 'none' },
254
+ h('path', {
255
+ d: 'M15.3 8.2c-.8 0-1.6.5-2.1.5s-1.2-.5-2-.5c-1.7 0-3.4 1.4-3.4 3.9 0 2.5 1.7 5.2 3.1 5.2.6 0 1.2-.4 1.9-.4.7 0 1.3.4 1.9.4 1.4 0 3-2.7 3-3.4-1.9-.8-2.2-3.2-.4-4.4-.5-.8-1.2-1.3-1.9-1.3zM13.4 8.3c.7-.8.9-1.7.8-2.4-.8.1-1.7.6-2.2 1.2-.4.5-.8 1.3-.7 2.1.8.1 1.6-.4 2.1-.9z',
256
+ }),
257
+ )
258
+ case 'cloud':
259
+ return h('svg', p, h('path', { d: 'M7 18a4 4 0 0 1 .6-7.95A5.5 5.5 0 0 1 18.2 11 3.5 3.5 0 0 1 17.5 18Z' }))
260
+ case 'refresh':
261
+ return h('svg', p, h('path', { d: 'M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6' }))
262
+ case 'plus':
263
+ return h('svg', p, h('path', { d: 'M12 5v14M5 12h14' }))
264
+ case 'chevron':
265
+ return h('svg', p, h('path', { d: 'm6 9 6 6 6-6' }))
266
+ case 'back':
267
+ return h('svg', p, h('path', { d: 'm15 18-6-6 6-6' }))
268
+ case 'expand':
269
+ return h(
270
+ 'svg',
271
+ p,
272
+ h('path', { d: 'M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5' }),
273
+ )
274
+ case 'collapse':
275
+ return h(
276
+ 'svg',
277
+ p,
278
+ h('path', { d: 'M4 9h5V4M20 9h-5V4M4 15h5v5M20 15h-5v5' }),
279
+ )
280
+ case 'navBack':
281
+ return h(
282
+ 'svg',
283
+ { ...p, fill: 'currentColor', stroke: 'none' },
284
+ h('path', { d: 'M15.2 6.5a.7.7 0 0 0-1.1-.6l-7 5.4a.9.9 0 0 0 0 1.4l7 5.4a.7.7 0 0 0 1.1-.6V6.5Z' }),
285
+ )
286
+ case 'home':
287
+ return h('svg', p, h('circle', { cx: 12, cy: 12, r: 7.5 }))
288
+ case 'recents':
289
+ return h('svg', p, h('rect', { x: 5.5, y: 5.5, width: 13, height: 13, rx: 2 }))
290
+ case 'camera':
291
+ return h(
292
+ 'svg',
293
+ p,
294
+ h('rect', { x: 3, y: 6, width: 18, height: 14, rx: 2.5 }),
295
+ h('circle', { cx: 12, cy: 13, r: 3.4 }),
296
+ h('path', { d: 'M8 6l1.4-2h5.2L16 6' }),
297
+ )
298
+ case 'more':
299
+ return h(
300
+ 'svg',
301
+ { ...p, fill: 'currentColor', stroke: 'none' },
302
+ h('circle', { cx: 5, cy: 12, r: 1.5 }),
303
+ h('circle', { cx: 12, cy: 12, r: 1.5 }),
304
+ h('circle', { cx: 19, cy: 12, r: 1.5 }),
305
+ )
306
+ case 'power':
307
+ return h('svg', p, h('path', { d: 'M12 3v9' }), h('path', { d: 'M6.4 6.4a8 8 0 1 0 11.2 0' }))
308
+ case 'rotate':
309
+ return h(
310
+ 'svg',
311
+ p,
312
+ h('rect', { x: 7.5, y: 4.5, width: 9, height: 15, rx: 2 }),
313
+ h('path', { d: 'M4.5 19a9 9 0 0 1 14-8.5' }),
314
+ h('path', { d: 'M18.5 6.5v4h-4' }),
315
+ )
316
+ case 'link':
317
+ return h(
318
+ 'svg',
319
+ p,
320
+ h('path', { d: 'M10 13a5 5 0 0 0 7.07 0l2-2a5 5 0 0 0-7.07-7.07l-1 1' }),
321
+ h('path', { d: 'M14 11a5 5 0 0 0-7.07 0l-2 2a5 5 0 0 0 7.07 7.07l1-1' }),
322
+ )
323
+ case 'unlink':
324
+ return h(
325
+ 'svg',
326
+ p,
327
+ h('path', { d: 'M15 7l2-2a5 5 0 0 1 7 7l-2 2' }),
328
+ h('path', { d: 'M9 17l-2 2a5 5 0 0 1-7-7l2-2' }),
329
+ h('path', { d: 'M4 4l16 16' }),
330
+ )
331
+ case 'info':
332
+ return h(
333
+ 'svg',
334
+ p,
335
+ h('circle', { cx: 12, cy: 12, r: 8.5 }),
336
+ h('path', { d: 'M12 11v5' }),
337
+ h('path', { d: 'M12 8h.01' }),
338
+ )
339
+ case 'float':
340
+ return h(
341
+ 'svg',
342
+ p,
343
+ h('rect', { x: 3, y: 5, width: 18, height: 14, rx: 2.5 }),
344
+ h('rect', { x: 12, y: 11, width: 7, height: 6, rx: 1.2 }),
345
+ )
346
+ case 'right':
347
+ return h('svg', p, h('path', { d: 'm9 6 6 6-6 6' }))
348
+ case 'copy':
349
+ return h(
350
+ 'svg',
351
+ p,
352
+ h('rect', { x: 9, y: 9, width: 11, height: 11, rx: 2 }),
353
+ h('path', { d: 'M5 15V5a2 2 0 0 1 2-2h8' }),
354
+ )
355
+ case 'x':
356
+ return h('svg', p, h('path', { d: 'm6 6 12 12M18 6 6 18' }))
357
+ case 'check':
358
+ return h('svg', p, h('path', { d: 'm5 13 4 4L19 7' }))
359
+ default:
360
+ return null
361
+ }
362
+ }
363
+
364
+ const PhoneGlyph = (props) => h(Icon, { name: 'phone', size: (props && props.size) || 18 })
365
+
366
+ // ---------------------------------------------------------------- api
367
+ function createApi() {
368
+ const unwrap = async (endpoint, payload) => {
369
+ const res = await fetch('phone/' + endpoint, {
370
+ method: 'POST',
371
+ headers: { 'content-type': 'application/json' },
372
+ body: JSON.stringify(payload || {}),
373
+ credentials: 'same-origin',
374
+ })
375
+ const type = res.headers.get('content-type') || ''
376
+ if (!type.includes('application/json')) {
377
+ if (!res.ok) throw new Error('HTTP ' + res.status)
378
+ return res
379
+ }
380
+ const body = await res.json()
381
+ if (body && body.ok) return body.value
382
+ const message = body && body.error && body.error.message ? body.error.message : 'rpc failed: ' + endpoint
383
+ throw new Error(message)
384
+ }
385
+ return {
386
+ health: () => unwrap('health'),
387
+ devices: () => unwrap('devices'),
388
+ connect: (address) => unwrap('connect', { address }),
389
+ disconnect: (serial) => unwrap('disconnect', { serial }),
390
+ input: (serial, event) => unwrap('input', { serial, event }),
391
+ screenInfo: (serial) => unwrap('screen-info', { serial }),
392
+ targets: () => unwrap('targets'),
393
+ removeTarget: (address) => unwrap('targets/remove', { address }),
394
+ shot: async (serial) => {
395
+ const res = await unwrap('shot', { serial })
396
+ return res.blob()
397
+ },
398
+ }
399
+ }
400
+
401
+ const api = createApi()
402
+
403
+ // ---------------------------------------------------------------- hooks
404
+ function useDeviceList(visible, nonce) {
405
+ const [devices, setDevices] = useState(null)
406
+ const [error, setError] = useState(null)
407
+ const load = useCallback(async () => {
408
+ try {
409
+ const value = await api.devices()
410
+ setDevices(Array.isArray(value.devices) ? value.devices : [])
411
+ setError(null)
412
+ } catch (err) {
413
+ setError(err && err.message ? err.message : 'devices failed')
414
+ }
415
+ }, [])
416
+ useEffect(() => {
417
+ if (!visible) return undefined
418
+ let alive = true
419
+ let timer = null
420
+ const tick = async () => {
421
+ if (!alive) return
422
+ await load()
423
+ if (alive) timer = setTimeout(tick, 5000)
424
+ }
425
+ tick()
426
+ return () => {
427
+ alive = false
428
+ if (timer) clearTimeout(timer)
429
+ }
430
+ }, [visible, load, nonce])
431
+ return { devices, error, reload: load }
432
+ }
433
+
434
+ function kindForAddress(address) {
435
+ if (/^unicom-openclaw-|\.ts\.net/.test(address)) return 'tailscale'
436
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address)) return 'tailscale'
437
+ if (address.includes(':')) return 'wifi'
438
+ return 'usb'
439
+ }
440
+
441
+ const QUALITY_ORDER = ['low', 'medium', 'high']
442
+
443
+ function qualityLabel(quality, t) {
444
+ if (quality === 'low') return t('qualityLow')
445
+ if (quality === 'high') return t('qualityHigh')
446
+ return t('qualityMedium')
447
+ }
448
+
449
+ function platformLabel(platform) {
450
+ if (platform === 'android') return 'Android'
451
+ if (platform === 'harmony') return 'HarmonyOS'
452
+ if (platform === 'ios') return 'iOS'
453
+ return null
454
+ }
455
+
456
+ function kindLabel(kind) {
457
+ if (kind === 'tailscale') return 'Tailscale'
458
+ if (kind === 'wifi') return 'Wi-Fi'
459
+ if (kind === 'emulator') return 'emulator'
460
+ return 'USB'
461
+ }
462
+
463
+ function stateLabel(state, t) {
464
+ if (state === 'device') return null
465
+ if (state === 'saved') return t('saved')
466
+ if (state === 'unauthorized') return t('unauthorized')
467
+ if (state === 'offline') return t('offline')
468
+ return state
469
+ }
470
+
471
+ // ---------------------------------------------------------------- components
472
+ function DeviceRows(props) {
473
+ const { t, devices, selected, onSelect, compact } = props
474
+ const order = { usb: 0, emulator: 1, wifi: 2, tailscale: 3 }
475
+ const sorted = [...devices].sort((a, b) => {
476
+ const bySaved = (a.saved ? 1 : 0) - (b.saved ? 1 : 0)
477
+ if (bySaved !== 0) return bySaved
478
+ return (order[a.kind] ?? 9) - (order[b.kind] ?? 9)
479
+ })
480
+ return h(
481
+ 'div',
482
+ { className: compact ? 'dph-menu-list' : 'dph-list' },
483
+ sorted.map((device) =>
484
+ h(
485
+ 'button',
486
+ {
487
+ key: device.serial,
488
+ type: 'button',
489
+ className: 'dph-row' + (device.serial === selected ? ' sel' : ''),
490
+ onClick: () => onSelect(device),
491
+ },
492
+ h(
493
+ 'span',
494
+ { className: 'dph-av' },
495
+ h(Icon, {
496
+ name: device.saved
497
+ ? 'link'
498
+ : device.platform === 'harmony'
499
+ ? 'harmony'
500
+ : device.platform === 'ios'
501
+ ? 'apple'
502
+ : device.platform === 'android'
503
+ ? 'android'
504
+ : device.kind === 'tailscale'
505
+ ? 'cloud'
506
+ : 'phone',
507
+ size: 22,
508
+ }),
509
+ h('span', {
510
+ className:
511
+ device.state === 'device' ? 'on' : device.state === 'unauthorized' ? 'on warn' : 'on off',
512
+ }),
513
+ ),
514
+ h(
515
+ 'span',
516
+ { className: 'dph-meta' },
517
+ h(
518
+ 'span',
519
+ { className: 'dph-model' },
520
+ device.model,
521
+ h('span', { className: 'dph-tag' }, kindLabel(device.kind)),
522
+ platformLabel(device.platform)
523
+ ? h('span', { className: 'dph-tag dph-tag-os' }, platformLabel(device.platform))
524
+ : null,
525
+ ),
526
+ device.serial && device.serial !== device.model
527
+ ? h('span', { className: 'dph-serial' }, device.serial)
528
+ : null,
529
+ ),
530
+ stateLabel(device.state, t)
531
+ ? h('span', { className: 'dph-state' + (device.state === 'saved' ? ' saved' : '') }, stateLabel(device.state, t))
532
+ : null,
533
+ h(Icon, { name: 'right', size: 13, className: 'dph-chev' }),
534
+ ),
535
+ ),
536
+ )
537
+ }
538
+
539
+ function HeaderButton(props) {
540
+ const { t, open } = props
541
+ const [count, setCount] = useState(null)
542
+ useEffect(() => {
543
+ let alive = true
544
+ let timer = null
545
+ const tick = async () => {
546
+ try {
547
+ const value = await api.devices()
548
+ const ready = (value.devices || []).filter((device) => device.state === 'device').length
549
+ if (alive) setCount(ready)
550
+ } catch {
551
+ if (alive) setCount(null)
552
+ }
553
+ if (alive) timer = setTimeout(tick, 20000)
554
+ }
555
+ tick()
556
+ return () => {
557
+ alive = false
558
+ if (timer) clearTimeout(timer)
559
+ }
560
+ }, [])
561
+ return h(
562
+ 'button',
563
+ {
564
+ type: 'button',
565
+ className: 'dph-headbtn',
566
+ title: t('title'),
567
+ 'aria-label': t('title'),
568
+ onClick: () => {
569
+ try {
570
+ open()
571
+ } catch {}
572
+ },
573
+ },
574
+ h(Icon, { name: 'phone', size: 16 }),
575
+ count ? h('span', { className: 'dph-count' }, String(count)) : null,
576
+ )
577
+ }
578
+
579
+ // ---------------------------------------------------------------- preview
580
+ // H.264 流(P1):screenrecord 输出 Annex-B,WebCodecs 需要 avcC description +
581
+ // 长度前缀分片。不支持 WebCodecs 的环境自动回退到截图轮询。
582
+ const CAN_STREAM =
583
+ typeof window !== 'undefined' &&
584
+ typeof window.VideoDecoder === 'function' &&
585
+ typeof window.ReadableStream === 'function' &&
586
+ typeof window.fetch === 'function'
587
+
588
+ function concatBytes(a, b) {
589
+ if (!a.length) return b
590
+ const out = new Uint8Array(a.length + b.length)
591
+ out.set(a)
592
+ out.set(b, a.length)
593
+ return out
594
+ }
595
+
596
+ // Annex-B 增量解析。返回 { push, flush }:
597
+ // - push 只在找到「下一个起始码」时才能界定一个 NAL,所以最后一个 NAL 会留在 buffer
598
+ // - 静止画面可能几秒没有下一帧,必须靠 flush(空闲定时器)把尾部 NAL 交出去
599
+ function createNalParser(onNal) {
600
+ let buffer = new Uint8Array(0)
601
+ const findStart = (from) => {
602
+ for (let i = from; i + 2 < buffer.length; i += 1) {
603
+ if (buffer[i] !== 0 || buffer[i + 1] !== 0) continue
604
+ if (buffer[i + 2] === 1) return { at: i, len: 3 }
605
+ if (i + 3 < buffer.length && buffer[i + 2] === 0 && buffer[i + 3] === 1) return { at: i, len: 4 }
606
+ }
607
+ return null
608
+ }
609
+ const push = (chunk) => {
610
+ buffer = concatBytes(buffer, chunk)
611
+ for (;;) {
612
+ const first = findStart(0)
613
+ if (!first) {
614
+ if (buffer.length > 3) buffer = buffer.slice(buffer.length - 3)
615
+ return
616
+ }
617
+ if (first.at > 0) buffer = buffer.slice(first.at)
618
+ const second = findStart(first.len)
619
+ if (!second) return
620
+ const payload = buffer.slice(first.len, second.at)
621
+ buffer = buffer.slice(second.at)
622
+ if (payload.length > 0) onNal(payload)
623
+ }
624
+ }
625
+ const flush = () => {
626
+ const first = findStart(0)
627
+ if (!first) {
628
+ buffer = new Uint8Array(0)
629
+ return
630
+ }
631
+ const payload = buffer.slice(first.len)
632
+ buffer = new Uint8Array(0)
633
+ if (payload.length > 0) onNal(payload)
634
+ }
635
+ return { push, flush }
636
+ }
637
+
638
+ function buildAvcc(sps, pps) {
639
+ const codec = 'avc1.' + [sps[1], sps[2], sps[3]].map((value) => value.toString(16).padStart(2, '0')).join('')
640
+ const description = new Uint8Array([
641
+ 1, sps[1], sps[2], sps[3], 0xff, 0xe1, (sps.length >> 8) & 0xff, sps.length & 0xff, ...sps,
642
+ 1, (pps.length >> 8) & 0xff, pps.length & 0xff, ...pps,
643
+ ])
644
+ return { codec, description }
645
+ }
646
+
647
+ function useStream(serial, visible, canvasRef, onToast, attempt, quality, onSize) {
648
+ const [status, setStatus] = useState('idle')
649
+ useEffect(() => {
650
+ if (!serial || !visible || !CAN_STREAM) {
651
+ setStatus(serial ? 'fallback' : 'idle')
652
+ return undefined
653
+ }
654
+ const controller = new AbortController()
655
+ let alive = true
656
+ let decoder = null
657
+ let sps = null
658
+ let pps = null
659
+ let frames = 0
660
+ let waitingKey = true
661
+ let reportedSize = false
662
+
663
+ const resetDecoder = () => {
664
+ if (decoder) {
665
+ try {
666
+ decoder.close()
667
+ } catch {}
668
+ }
669
+ decoder = null
670
+ waitingKey = true
671
+ }
672
+
673
+ const ensureDecoder = () => {
674
+ if (decoder) return true
675
+ if (!sps || !pps) return false
676
+ const { codec, description } = buildAvcc(sps, pps)
677
+ decoder = new VideoDecoder({
678
+ output: (frame) => {
679
+ try {
680
+ const canvas = canvasRef.current
681
+ if (canvas) {
682
+ if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
683
+ canvas.width = frame.displayWidth
684
+ canvas.height = frame.displayHeight
685
+ }
686
+ const ctx = canvas.getContext('2d')
687
+ if (ctx) ctx.drawImage(frame, 0, 0)
688
+ }
689
+ frames += 1
690
+ waitingKey = false
691
+ if (!reportedSize) {
692
+ reportedSize = true
693
+ if (onSize) onSize(frame.displayWidth, frame.displayHeight)
694
+ }
695
+ } finally {
696
+ frame.close()
697
+ }
698
+ },
699
+ error: () => resetDecoder(),
700
+ })
701
+ decoder.configure({ codec, description, optimizeForLatency: true })
702
+ return true
703
+ }
704
+
705
+ const feed = (nal) => {
706
+ const type = nal[0] & 0x1f
707
+ if (type === 7) {
708
+ sps = nal
709
+ return
710
+ }
711
+ if (type === 8) {
712
+ pps = nal
713
+ return
714
+ }
715
+ if (type !== 1 && type !== 5) return
716
+ if (!ensureDecoder()) return
717
+ if (waitingKey && type !== 5) return
718
+ if (decoder.decodeQueueSize > 16) {
719
+ resetDecoder()
720
+ return
721
+ }
722
+ const chunk = new Uint8Array(4 + nal.length)
723
+ new DataView(chunk.buffer).setUint32(0, nal.length)
724
+ chunk.set(nal, 4)
725
+ try {
726
+ decoder.decode(
727
+ new EncodedVideoChunk({
728
+ type: type === 5 ? 'key' : 'delta',
729
+ timestamp: frames * 33333,
730
+ data: chunk,
731
+ }),
732
+ )
733
+ } catch {
734
+ resetDecoder()
735
+ }
736
+ }
737
+
738
+ setStatus('connecting')
739
+ ;(async () => {
740
+ const response = await fetch('phone/stream', {
741
+ method: 'POST',
742
+ headers: { 'content-type': 'application/json' },
743
+ body: JSON.stringify({ serial, quality }),
744
+ signal: controller.signal,
745
+ })
746
+ const type = response.headers.get('content-type') || ''
747
+ if (type.includes('application/json')) {
748
+ const body = await response.json()
749
+ throw new Error((body && body.error && body.error.message) || 'stream rejected')
750
+ }
751
+ if (!response.ok || !response.body) throw new Error('stream HTTP ' + response.status)
752
+ if (!alive) return
753
+ setStatus('live')
754
+ const reader = response.body.getReader()
755
+ const parser = createNalParser(feed)
756
+ let idleTimer = null
757
+ const armFlush = () => {
758
+ if (idleTimer) clearTimeout(idleTimer)
759
+ idleTimer = setTimeout(() => {
760
+ try {
761
+ parser.flush()
762
+ } catch {}
763
+ }, 250)
764
+ }
765
+ for (;;) {
766
+ const { done, value } = await reader.read()
767
+ if (done || !alive) break
768
+ if (value) {
769
+ parser.push(value)
770
+ armFlush()
771
+ }
772
+ }
773
+ if (idleTimer) clearTimeout(idleTimer)
774
+ try {
775
+ parser.flush()
776
+ } catch {}
777
+ if (alive) setStatus('fallback')
778
+ })().catch((error) => {
779
+ if (!alive) return
780
+ if (error && error.name === 'AbortError') return
781
+ setStatus('fallback')
782
+ if (onToast) onToast(error && error.message ? error.message : 'stream unavailable, falling back to screenshots')
783
+ })
784
+
785
+ return () => {
786
+ alive = false
787
+ controller.abort()
788
+ resetDecoder()
789
+ }
790
+ }, [serial, visible, attempt, quality])
791
+ return status
792
+ }
793
+
794
+ function usePreview(serial, visible, t, quality, immersive) {
795
+ const stageRef = useRef(null)
796
+ const canvasRef = useRef(null)
797
+ const pointerRef = useRef(null)
798
+ const urlRef = useRef(null)
799
+ const [frameUrl, setFrameUrl] = useState(null)
800
+ const [ratio, setRatio] = useState(1080 / 2400)
801
+ const [deviceSize, setDeviceSize] = useState({ width: 1080, height: 2400 })
802
+ const [size, setSize] = useState(null)
803
+ const [error, setError] = useState(null)
804
+ const [loading, setLoading] = useState(true)
805
+ const [attempt, setAttempt] = useState(0)
806
+ 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
+ })
810
+
811
+ useEffect(() => {
812
+ if (!serial) return undefined
813
+ 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(() => {})
823
+ return () => {
824
+ alive = false
825
+ }
826
+ }, [serial])
827
+
828
+ useEffect(() => {
829
+ const el = stageRef.current
830
+ if (!el) return undefined
831
+ const measure = () => {
832
+ const width = el.clientWidth
833
+ const height = el.clientHeight
834
+ if (!width || !height) return
835
+ const availW = Math.max(60, width - 24)
836
+ const availH = Math.max(120, height - (immersive ? 28 : 98))
837
+ const screenW = Math.min(availW, availH * ratio)
838
+ setSize({ w: Math.round(screenW), h: Math.round(screenW / ratio) })
839
+ }
840
+ measure()
841
+ let observer = null
842
+ if (typeof ResizeObserver !== 'undefined') {
843
+ observer = new ResizeObserver(measure)
844
+ observer.observe(el)
845
+ }
846
+ window.addEventListener('resize', measure)
847
+ return () => {
848
+ if (observer) observer.disconnect()
849
+ window.removeEventListener('resize', measure)
850
+ }
851
+ // serial 变化时 stage 才挂载,effect 需要重跑一次才能量到尺寸
852
+ }, [ratio, serial, immersive])
853
+
854
+ useEffect(() => {
855
+ if (CAN_STREAM && streamStatus !== 'fallback') return undefined
856
+ if (!serial || !visible) return undefined
857
+ let alive = true
858
+ let timer = null
859
+ setLoading(true)
860
+ setError(null)
861
+ const tick = async () => {
862
+ if (!alive) return
863
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
864
+ timer = setTimeout(tick, 1200)
865
+ return
866
+ }
867
+ try {
868
+ const blob = await api.shot(serial)
869
+ if (!alive) return
870
+ const url = URL.createObjectURL(blob)
871
+ if (urlRef.current) URL.revokeObjectURL(urlRef.current)
872
+ urlRef.current = url
873
+ setFrameUrl(url)
874
+ setError(null)
875
+ setLoading(false)
876
+ } catch (err) {
877
+ if (!alive) return
878
+ setError(err && err.message ? err.message : 'shot failed')
879
+ setLoading(false)
880
+ }
881
+ if (alive) timer = setTimeout(tick, 700)
882
+ }
883
+ tick()
884
+ return () => {
885
+ alive = false
886
+ if (timer) clearTimeout(timer)
887
+ }
888
+ }, [serial, visible, attempt, streamStatus])
889
+
890
+ useEffect(
891
+ () => () => {
892
+ if (urlRef.current) URL.revokeObjectURL(urlRef.current)
893
+ urlRef.current = null
894
+ },
895
+ [],
896
+ )
897
+
898
+ useEffect(() => {
899
+ if (!toast) return undefined
900
+ const timer = setTimeout(() => setToast(null), 1600)
901
+ return () => clearTimeout(timer)
902
+ }, [toast])
903
+
904
+ const send = useCallback(
905
+ (event) => {
906
+ api.input(serial, event).catch((err) => setToast(err && err.message ? err.message : 'input failed'))
907
+ },
908
+ [serial],
909
+ )
910
+
911
+ const onImageLoad = (event) => {
912
+ const img = event.currentTarget
913
+ if (img && img.naturalWidth && img.naturalHeight) {
914
+ setRatio(img.naturalWidth / img.naturalHeight)
915
+ setDeviceSize({ width: img.naturalWidth, height: img.naturalHeight })
916
+ }
917
+ }
918
+
919
+ const pointOf = (element, clientX, clientY) => {
920
+ const rect = element.getBoundingClientRect()
921
+ const width = deviceSize.width || 1080
922
+ const height = deviceSize.height || 2400
923
+ return [
924
+ Math.round(Math.min(width - 1, Math.max(0, ((clientX - rect.left) / rect.width) * width))),
925
+ Math.round(Math.min(height - 1, Math.max(0, ((clientY - rect.top) / rect.height) * height))),
926
+ ]
927
+ }
928
+
929
+ const onPointerDown = (event) => {
930
+ if (event.button !== undefined && event.button !== 0) return
931
+ pointerRef.current = { t: Date.now(), x: event.clientX, y: event.clientY, el: event.currentTarget }
932
+ try {
933
+ event.currentTarget.setPointerCapture(event.pointerId)
934
+ } catch {}
935
+ }
936
+
937
+ const onPointerUp = (event) => {
938
+ const start = pointerRef.current
939
+ pointerRef.current = null
940
+ if (!start) return
941
+ const element = start.el
942
+ const dt = Date.now() - start.t
943
+ const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y)
944
+ if (dt < 400 && distance < 8) {
945
+ const point = pointOf(element, event.clientX, event.clientY)
946
+ send({ action: 'tap', x: point[0], y: point[1] })
947
+ return
948
+ }
949
+ const from = pointOf(element, start.x, start.y)
950
+ const to = pointOf(element, event.clientX, event.clientY)
951
+ send({ action: 'swipe', x1: from[0], y1: from[1], x2: to[0], y2: to[1], duration: Math.max(80, Math.min(1200, dt)) })
952
+ }
953
+
954
+ const screenshot = async () => {
955
+ try {
956
+ const blob = await api.shot(serial)
957
+ const url = URL.createObjectURL(blob)
958
+ const anchor = document.createElement('a')
959
+ anchor.href = url
960
+ anchor.download = 'dsh-phone-' + String(serial).replace(/[^A-Za-z0-9._-]/g, '_') + '-' + Date.now() + '.png'
961
+ document.body.appendChild(anchor)
962
+ anchor.click()
963
+ anchor.remove()
964
+ setTimeout(() => URL.revokeObjectURL(url), 3000)
965
+ setToast('screenshot saved')
966
+ } catch (err) {
967
+ setToast(err && err.message ? err.message : 'shot failed')
968
+ }
969
+ }
970
+
971
+ const retry = () => {
972
+ setError(null)
973
+ setLoading(true)
974
+ setAttempt((value) => value + 1)
975
+ }
976
+
977
+ const useCanvas = CAN_STREAM && (streamStatus === 'connecting' || streamStatus === 'live')
978
+ const loadingNow = useCanvas ? streamStatus === 'connecting' : loading && !frameUrl
979
+ const errorNow = useCanvas ? null : error
980
+
981
+ return {
982
+ stageRef,
983
+ canvasRef,
984
+ canStream: CAN_STREAM,
985
+ useCanvas,
986
+ streamStatus,
987
+ size,
988
+ ratio,
989
+ immersive,
990
+ deviceSize,
991
+ frameUrl,
992
+ error: errorNow,
993
+ loading: loadingNow,
994
+ toast,
995
+ setToast,
996
+ send,
997
+ screenshot,
998
+ retry,
999
+ onImageLoad,
1000
+ onPointerDown,
1001
+ onPointerUp,
1002
+ }
1003
+ }
1004
+
1005
+ function PhoneTabBody(props) {
1006
+ const { t, useTabInfo, floatTab } = props
1007
+ const info = typeof useTabInfo === 'function' ? useTabInfo() : null
1008
+ const tab = info && info.tab ? info.tab : null
1009
+ const visible = tab ? tab.visible !== false : true
1010
+
1011
+ const [selected, setSelected] = useState(null)
1012
+ const [menuOpen, setMenuOpen] = useState(false)
1013
+ const [moreOpen, setMoreOpen] = useState(false)
1014
+ const [connectOpen, setConnectOpen] = useState(false)
1015
+ const [address, setAddress] = useState('')
1016
+ const [connecting, setConnecting] = useState(false)
1017
+ const [connectError, setConnectError] = useState(null)
1018
+ const [nonce, setNonce] = useState(0)
1019
+ const [health, setHealth] = useState(null)
1020
+ const [qualityMap, setQualityMap] = useState(() => {
1021
+ try {
1022
+ const raw = JSON.parse(localStorage.getItem('dsh-phone-quality-map') || '{}')
1023
+ return raw && typeof raw === 'object' ? raw : {}
1024
+ } catch {
1025
+ return {}
1026
+ }
1027
+ })
1028
+ const [targets, setTargets] = useState([])
1029
+ const [diagOpen, setDiagOpen] = useState(false)
1030
+ const [immersive, setImmersive] = useState(() => {
1031
+ try {
1032
+ return localStorage.getItem('dsh-phone-immersive') === '1'
1033
+ } catch {
1034
+ return false
1035
+ }
1036
+ })
1037
+ const [disconnectArmed, setDisconnectArmed] = useState(false)
1038
+ const { devices, error, reload } = useDeviceList(visible, nonce)
1039
+ const quality = selected && QUALITY_ORDER.includes(qualityMap[selected]) ? qualityMap[selected] : 'medium'
1040
+ const setQualityForSelected = (next) => {
1041
+ if (!selected) return
1042
+ setQualityMap((previous) => {
1043
+ const map = { ...previous, [selected]: next }
1044
+ try {
1045
+ localStorage.setItem('dsh-phone-quality-map', JSON.stringify(map))
1046
+ } catch {}
1047
+ return map
1048
+ })
1049
+ }
1050
+ const preview = usePreview(selected, visible && Boolean(selected), t, quality, immersive)
1051
+
1052
+ useEffect(() => {
1053
+ let alive = true
1054
+ api
1055
+ .health()
1056
+ .then((value) => {
1057
+ if (alive) setHealth(value)
1058
+ })
1059
+ .catch(() => {
1060
+ if (alive) setHealth({ adb: null, error: 'host unavailable' })
1061
+ })
1062
+ return () => {
1063
+ alive = false
1064
+ }
1065
+ }, [])
1066
+
1067
+ useEffect(() => {
1068
+ if (!visible) return undefined
1069
+ let alive = true
1070
+ api
1071
+ .targets()
1072
+ .then((value) => {
1073
+ if (alive) setTargets(Array.isArray(value.targets) ? value.targets : [])
1074
+ })
1075
+ .catch(() => {})
1076
+ return () => {
1077
+ alive = false
1078
+ }
1079
+ }, [visible, nonce])
1080
+
1081
+ const savedRows = (targets || [])
1082
+ .filter((target) => !(devices || []).some((item) => item.serial === target.address))
1083
+ .map((target) => ({
1084
+ serial: target.address,
1085
+ state: 'saved',
1086
+ kind: kindForAddress(target.address),
1087
+ model: target.address,
1088
+ saved: true,
1089
+ }))
1090
+ const rows = devices === null ? null : devices.concat(savedRows)
1091
+ const device = rows ? rows.find((item) => item.serial === selected) || null : null
1092
+ const tabId = tab && tab.id ? tab.id : null
1093
+ const adbMissing = health && health.adb === null
1094
+
1095
+ const connectTo = async (address) => {
1096
+ setConnecting(true)
1097
+ setConnectError(null)
1098
+ try {
1099
+ await api.connect(address)
1100
+ setConnectOpen(false)
1101
+ setAddress('')
1102
+ setNonce((value) => value + 1)
1103
+ return true
1104
+ } catch (err) {
1105
+ setConnectError(err && err.message ? err.message : 'connect failed')
1106
+ return false
1107
+ } finally {
1108
+ setConnecting(false)
1109
+ }
1110
+ }
1111
+
1112
+ const onSelect = (picked) => {
1113
+ setMenuOpen(false)
1114
+ if (picked && picked.saved) {
1115
+ connectTo(picked.serial).then((ok) => {
1116
+ if (ok) setSelected(picked.serial)
1117
+ })
1118
+ return
1119
+ }
1120
+ setSelected(picked.serial)
1121
+ }
1122
+
1123
+ const removeTarget = async (target) => {
1124
+ try {
1125
+ await api.removeTarget(target.address)
1126
+ setTargets((list) => list.filter((item) => item.address !== target.address))
1127
+ } catch (err) {
1128
+ preview.setToast(err && err.message ? err.message : 'remove failed')
1129
+ }
1130
+ }
1131
+
1132
+ const onConnect = async (event) => {
1133
+ if (event) event.preventDefault()
1134
+ if (!address.trim() || connecting) return
1135
+ await connectTo(address.trim())
1136
+ }
1137
+
1138
+ const copySerial = async () => {
1139
+ try {
1140
+ await navigator.clipboard.writeText(selected)
1141
+ preview.setToast(t('copied'))
1142
+ } catch {
1143
+ preview.setToast(selected)
1144
+ }
1145
+ }
1146
+
1147
+ const moreItems = [
1148
+ { id: 'power', icon: 'power', label: t('power'), run: () => preview.send({ action: 'key', key: 'power' }) },
1149
+ { id: 'rotate', icon: 'rotate', label: t('rotate'), run: () => preview.send({ action: 'rotate' }) },
1150
+ {
1151
+ id: 'reconnect',
1152
+ icon: 'link',
1153
+ label: t('reconnect'),
1154
+ run: () => {
1155
+ if (!selected || !selected.includes(':')) {
1156
+ preview.setToast(t('reconnect'))
1157
+ return
1158
+ }
1159
+ api
1160
+ .connect(selected)
1161
+ .then(() => preview.setToast(t('reconnect')))
1162
+ .catch((err) => preview.setToast(err && err.message ? err.message : 'reconnect failed'))
1163
+ },
1164
+ },
1165
+ {
1166
+ id: 'immersive',
1167
+ icon: immersive ? 'collapse' : 'expand',
1168
+ label: t(immersive ? 'immersiveOff' : 'immersiveOn'),
1169
+ run: () => {
1170
+ const next = !immersive
1171
+ setImmersive(next)
1172
+ try {
1173
+ localStorage.setItem('dsh-phone-immersive', next ? '1' : '0')
1174
+ } catch {}
1175
+ },
1176
+ },
1177
+ {
1178
+ id: 'diag',
1179
+ icon: 'info',
1180
+ label: t('diagnostics'),
1181
+ run: () => setDiagOpen(true),
1182
+ },
1183
+ {
1184
+ id: 'float',
1185
+ icon: 'float',
1186
+ label: t('float'),
1187
+ run: () => {
1188
+ if (!tabId || typeof floatTab !== 'function') {
1189
+ preview.setToast(t('float'))
1190
+ return
1191
+ }
1192
+ const ratio = preview.ratio || 1080 / 2400
1193
+ const height = Math.max(420, Math.min(760, window.innerHeight - 120))
1194
+ const width = Math.round((height - 100) * ratio + 64)
1195
+ floatTab(tabId, {
1196
+ x: Math.max(8, window.innerWidth - width - 24),
1197
+ y: 72,
1198
+ width,
1199
+ height,
1200
+ })
1201
+ },
1202
+ },
1203
+ { id: 'copy', icon: 'copy', label: t('copySerial'), run: copySerial },
1204
+ {
1205
+ id: 'list',
1206
+ icon: 'back',
1207
+ label: t('devices'),
1208
+ run: () => setSelected(null),
1209
+ },
1210
+ ]
1211
+
1212
+ const menu = menuOpen
1213
+ ? h(
1214
+ 'div',
1215
+ null,
1216
+ h('div', { className: 'dph-backdrop', onClick: () => setMenuOpen(false) }),
1217
+ h(
1218
+ 'div',
1219
+ { className: 'dph-menu' },
1220
+ selected
1221
+ ? h(
1222
+ 'button',
1223
+ {
1224
+ type: 'button',
1225
+ className: 'dph-row',
1226
+ onClick: () => {
1227
+ setMenuOpen(false)
1228
+ setSelected(null)
1229
+ },
1230
+ },
1231
+ h('span', { className: 'dph-av', style: { width: 26, height: 26, borderRadius: 7 } }, h(Icon, { name: 'back', size: 14 })),
1232
+ h('span', { className: 'dph-meta' }, h('span', { className: 'dph-model', style: { fontWeight: 400 } }, t('devices'))),
1233
+ )
1234
+ : null,
1235
+ devices && devices.length
1236
+ ? h(DeviceRows, { t, devices, selected, onSelect, compact: true })
1237
+ : h('div', { className: 'dph-emptypadding' }, t('empty')),
1238
+ h(
1239
+ 'div',
1240
+ { className: 'dph-menu-foot' },
1241
+ h(
1242
+ 'button',
1243
+ {
1244
+ type: 'button',
1245
+ className: 'dph-link',
1246
+ onClick: () => {
1247
+ setMenuOpen(false)
1248
+ setConnectOpen(true)
1249
+ },
1250
+ },
1251
+ '+ ' + t('add'),
1252
+ ),
1253
+ ),
1254
+ selected
1255
+ ? h(
1256
+ 'div',
1257
+ { className: 'dph-menu-foot' },
1258
+ h(
1259
+ 'button',
1260
+ {
1261
+ type: 'button',
1262
+ className: 'dph-link dph-danger' + (disconnectArmed ? ' armed' : ''),
1263
+ onClick: () => {
1264
+ if (!disconnectArmed) {
1265
+ setDisconnectArmed(true)
1266
+ setTimeout(() => setDisconnectArmed(false), 3000)
1267
+ return
1268
+ }
1269
+ setDisconnectArmed(false)
1270
+ setMenuOpen(false)
1271
+ api
1272
+ .disconnect(selected)
1273
+ .catch(() => {})
1274
+ .then(() => setSelected(null))
1275
+ },
1276
+ },
1277
+ disconnectArmed ? t('confirmDisconnect') : t('disconnect'),
1278
+ ),
1279
+ )
1280
+ : null,
1281
+ ),
1282
+ )
1283
+ : null
1284
+
1285
+ const connectSheet = connectOpen
1286
+ ? h(
1287
+ 'div',
1288
+ null,
1289
+ h('div', {
1290
+ className: 'dph-backdrop',
1291
+ onClick: () => {
1292
+ setConnectOpen(false)
1293
+ setConnectError(null)
1294
+ },
1295
+ }),
1296
+ h(
1297
+ 'form',
1298
+ { className: 'dph-connect', onSubmit: onConnect },
1299
+ h('div', { className: 'ttl' }, t('add')),
1300
+ h('div', { className: 'sub' }, 'adb connect <ip>:<port>'),
1301
+ h(
1302
+ 'div',
1303
+ { className: 'dph-field' },
1304
+ h('input', {
1305
+ className: 'dph-input',
1306
+ value: address,
1307
+ placeholder: '100.90.248.53:5555',
1308
+ onChange: (event) => setAddress(event.target.value),
1309
+ autoFocus: true,
1310
+ spellCheck: false,
1311
+ }),
1312
+ h('button', { type: 'submit', className: 'dph-primary', disabled: connecting || !address.trim() }, connecting ? t('connecting') : t('connect')),
1313
+ ),
1314
+ connectError ? h('div', { className: 'dph-err' }, connectError) : null,
1315
+ h(
1316
+ 'div',
1317
+ { className: 'dph-saved' },
1318
+ h('div', { className: 'dph-saved-title' }, t('saved')),
1319
+ targets.length === 0
1320
+ ? h('div', { className: 'dph-saved-empty' }, t('noSaved'))
1321
+ : targets.map((target) =>
1322
+ h(
1323
+ 'div',
1324
+ { className: 'dph-saved-row', key: target.address },
1325
+ h('span', { className: 'dph-saved-addr' }, target.address),
1326
+ h(
1327
+ 'button',
1328
+ {
1329
+ type: 'button',
1330
+ className: 'dph-link',
1331
+ onClick: () => connectTo(target.address),
1332
+ },
1333
+ t('connect'),
1334
+ ),
1335
+ h(
1336
+ 'button',
1337
+ {
1338
+ type: 'button',
1339
+ className: 'dph-link dph-link-muted',
1340
+ title: t('remove'),
1341
+ onClick: () => removeTarget(target),
1342
+ },
1343
+ '×',
1344
+ ),
1345
+ ),
1346
+ ),
1347
+ ),
1348
+ ),
1349
+ )
1350
+ : null
1351
+
1352
+ const diagText = [
1353
+ 'adb: ' + (health && health.adb ? (health.adb.version || '?') + ' @ ' + (health.adb.bin || '?') : t('noAdb')),
1354
+ 'devices: ' + (devices ? devices.length : '?'),
1355
+ ]
1356
+ .concat((devices || []).map((item) => item.serial + ' ' + item.state + ' ' + item.model))
1357
+ .join('\n')
1358
+
1359
+ const diagSheet = diagOpen
1360
+ ? h(
1361
+ 'div',
1362
+ null,
1363
+ h('div', { className: 'dph-backdrop', onClick: () => setDiagOpen(false) }),
1364
+ h(
1365
+ 'div',
1366
+ { className: 'dph-diag' },
1367
+ h('div', { className: 'ttl' }, t('diagnostics')),
1368
+ h('pre', { className: 'dph-diag-pre' }, diagText),
1369
+ h('div', { className: 'dph-saved-empty' }, t('diagHint')),
1370
+ h(
1371
+ 'div',
1372
+ { className: 'dph-field' },
1373
+ h(
1374
+ 'button',
1375
+ {
1376
+ type: 'button',
1377
+ className: 'dph-primary',
1378
+ onClick: async () => {
1379
+ try {
1380
+ await navigator.clipboard.writeText(diagText)
1381
+ preview.setToast(t('copied'))
1382
+ } catch {
1383
+ preview.setToast(t('copyDiag'))
1384
+ }
1385
+ },
1386
+ },
1387
+ t('copyDiag'),
1388
+ ),
1389
+ h('button', { type: 'button', className: 'dph-ghost', onClick: () => setDiagOpen(false) }, t('cancel')),
1390
+ ),
1391
+ ),
1392
+ )
1393
+ : null
1394
+
1395
+ const moreMenu = moreOpen
1396
+ ? h(
1397
+ 'div',
1398
+ null,
1399
+ h('div', { className: 'dph-backdrop', onClick: () => setMoreOpen(false) }),
1400
+ h(
1401
+ 'div',
1402
+ { className: 'dph-menu', style: { top: 40, left: 'auto', right: 8, width: 220 } },
1403
+ moreItems.map((item) =>
1404
+ h(
1405
+ 'button',
1406
+ {
1407
+ key: item.id,
1408
+ type: 'button',
1409
+ className: 'dph-row',
1410
+ onClick: () => {
1411
+ setMoreOpen(false)
1412
+ item.run()
1413
+ },
1414
+ },
1415
+ h('span', { className: 'dph-av', style: { width: 26, height: 26, borderRadius: 7 } }, h(Icon, { name: item.icon, size: 14 })),
1416
+ h('span', { className: 'dph-meta' }, h('span', { className: 'dph-model', style: { fontWeight: 400 } }, item.label)),
1417
+ ),
1418
+ ),
1419
+ ),
1420
+ )
1421
+ : null
1422
+
1423
+ const stage = h(
1424
+ 'div',
1425
+ { className: 'dph-stage' + (preview.immersive ? ' dph-stage-imm' : ''), ref: preview.stageRef },
1426
+ h(
1427
+ 'div',
1428
+ {
1429
+ className: 'dph-phone',
1430
+ style: preview.size ? { width: preview.size.w + 18, height: preview.size.h + 18 } : { width: 252, height: 548 },
1431
+ },
1432
+ h(
1433
+ 'div',
1434
+ { className: 'dph-screen' },
1435
+ preview.useCanvas
1436
+ ? h('canvas', {
1437
+ ref: preview.canvasRef,
1438
+ className: 'dph-canvas',
1439
+ onPointerDown: preview.onPointerDown,
1440
+ onPointerUp: preview.onPointerUp,
1441
+ })
1442
+ : preview.frameUrl
1443
+ ? h('img', {
1444
+ src: preview.frameUrl,
1445
+ alt: selected || 'device',
1446
+ draggable: false,
1447
+ onLoad: preview.onImageLoad,
1448
+ onPointerDown: preview.onPointerDown,
1449
+ onPointerUp: preview.onPointerUp,
1450
+ })
1451
+ : null,
1452
+ preview.loading
1453
+ ? h('div', { className: 'dph-overlay' }, h('div', { className: 'dph-spin' }), h('div', { className: 'msg' }, t('loading')))
1454
+ : null,
1455
+ preview.error
1456
+ ? h(
1457
+ 'div',
1458
+ { className: 'dph-overlay' },
1459
+ h('div', { className: 'msg' }, preview.error),
1460
+ h('button', { type: 'button', className: 'dph-btn', onClick: preview.retry }, t('retry')),
1461
+ )
1462
+ : null,
1463
+ ),
1464
+ ),
1465
+ )
1466
+
1467
+ const listBody = adbMissing
1468
+ ? h(
1469
+ 'div',
1470
+ { className: 'dph-emptypadding' },
1471
+ h('div', null, t('noAdb')),
1472
+ h('button', { type: 'button', className: 'dph-link', style: { marginTop: 8 }, onClick: () => setNonce((value) => value + 1) }, t('retry')),
1473
+ )
1474
+ : error
1475
+ ? h(
1476
+ 'div',
1477
+ { className: 'dph-emptypadding' },
1478
+ h('div', null, error.indexOf('404') >= 0 ? t('waitingHost') : error),
1479
+ h('button', { type: 'button', className: 'dph-link', style: { marginTop: 8 }, onClick: () => setNonce((value) => value + 1) }, t('retry')),
1480
+ )
1481
+ : rows === null
1482
+ ? h('div', { className: 'dph-emptypadding' }, t('loading'))
1483
+ : rows.length === 0
1484
+ ? h(
1485
+ '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')),
1489
+ )
1490
+ : h(DeviceRows, { t, devices: rows, selected, onSelect })
1491
+
1492
+ const rightButtons = selected
1493
+ ? [
1494
+ h(
1495
+ 'button',
1496
+ { key: 'rotate', type: 'button', className: 'dph-ibtn', title: t('rotate'), onClick: () => preview.send({ action: 'rotate' }) },
1497
+ h(Icon, { name: 'rotate' }),
1498
+ ),
1499
+ h(
1500
+ 'button',
1501
+ { key: 'more', type: 'button', className: 'dph-ibtn', title: t('more'), onClick: () => setMoreOpen((value) => !value) },
1502
+ h(Icon, { name: 'more' }),
1503
+ ),
1504
+ ]
1505
+ : [
1506
+ h(
1507
+ 'button',
1508
+ { key: 'refresh', type: 'button', className: 'dph-ibtn', title: t('refresh'), onClick: () => setNonce((value) => value + 1) },
1509
+ h(Icon, { name: 'refresh' }),
1510
+ ),
1511
+ h(
1512
+ 'button',
1513
+ { key: 'add', type: 'button', className: 'dph-ibtn', title: t('add'), onClick: () => setConnectOpen(true) },
1514
+ h(Icon, { name: 'plus' }),
1515
+ ),
1516
+ ]
1517
+
1518
+ const navbar = selected && !preview.immersive
1519
+ ? h(
1520
+ 'div',
1521
+ { className: 'dph-navbar' },
1522
+ h(
1523
+ 'button',
1524
+ { type: 'button', className: 'dph-navbtn', title: t('back'), onClick: () => preview.send({ action: 'key', key: 'back' }) },
1525
+ h(Icon, { name: 'navBack', size: 20 }),
1526
+ ),
1527
+ h(
1528
+ 'button',
1529
+ { type: 'button', className: 'dph-navbtn', title: t('home'), onClick: () => preview.send({ action: 'key', key: 'home' }) },
1530
+ h(Icon, { name: 'home', size: 19 }),
1531
+ ),
1532
+ h(
1533
+ 'button',
1534
+ { type: 'button', className: 'dph-navbtn', title: t('recents'), onClick: () => preview.send({ action: 'key', key: 'recents' }) },
1535
+ h(Icon, { name: 'recents', size: 19 }),
1536
+ ),
1537
+ h('div', { className: 'dph-navsep' }),
1538
+ h(
1539
+ 'button',
1540
+ { type: 'button', className: 'dph-navbtn', title: t('screenshot'), onClick: preview.screenshot },
1541
+ h(Icon, { name: 'camera', size: 19 }),
1542
+ ),
1543
+ )
1544
+ : null
1545
+
1546
+ return h(
1547
+ 'div',
1548
+ { className: 'dph-root' },
1549
+ h(
1550
+ 'div',
1551
+ { className: 'dph-bar' },
1552
+ h(
1553
+ 'button',
1554
+ { type: 'button', className: 'dph-select', onClick: () => setMenuOpen(true), title: t('select') },
1555
+ h(Icon, { name: 'phone', size: 14 }),
1556
+ h('span', { className: 'nm' }, device ? device.model : t('select')),
1557
+ h(Icon, { name: 'chevron', size: 12, className: 'chev' }),
1558
+ ),
1559
+ selected
1560
+ ? h(
1561
+ 'button',
1562
+ {
1563
+ type: 'button',
1564
+ className: 'dph-quality',
1565
+ title: t('quality'),
1566
+ onClick: () => {
1567
+ const next = QUALITY_ORDER[(QUALITY_ORDER.indexOf(quality) + 1) % QUALITY_ORDER.length]
1568
+ setQualityForSelected(next)
1569
+ },
1570
+ },
1571
+ qualityLabel(quality, t),
1572
+ )
1573
+ : null,
1574
+ h('div', { className: 'dph-grow' }),
1575
+ rightButtons,
1576
+ ),
1577
+ selected ? stage : listBody,
1578
+ navbar,
1579
+ preview.toast ? h('div', { className: 'dph-toast' }, preview.toast) : null,
1580
+ menu,
1581
+ moreMenu,
1582
+ connectSheet,
1583
+ diagSheet,
1584
+ )
1585
+ }
1586
+
1587
+ // ---------------------------------------------------------------- apply
1588
+ return {
1589
+ inject: ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs'],
1590
+ apply(ctx) {
1591
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-phone: locale')
1592
+ ctx.effect(() => {
1593
+ const tag = document.createElement('style')
1594
+ tag.id = 'dsh-phone-style'
1595
+ tag.textContent = CSS
1596
+ document.head.appendChild(tag)
1597
+ return () => tag.remove()
1598
+ }, 'dsh-phone: styles')
1599
+
1600
+ ctx.slots.inject('conversation.session.header.utilities', () =>
1601
+ ctx.slots.register(
1602
+ {
1603
+ name: 'conversation.session.header.utilities',
1604
+ id: 'dsh-phone',
1605
+ order: 10,
1606
+ locale: NS,
1607
+ inject: () => ({
1608
+ open: () => ctx.sidebarRight.openTab('phone'),
1609
+ }),
1610
+ },
1611
+ HeaderButton,
1612
+ ),
1613
+ )
1614
+
1615
+ ctx.effect(
1616
+ () =>
1617
+ ctx.sidebarRightTabs.register({
1618
+ id: 'dsh-phone',
1619
+ kind: 'phone',
1620
+ multiple: false,
1621
+ priority: 'builtin',
1622
+ title: () => (ctx.locale.bind ? ctx.locale.bind(NS)('title') : '手机'),
1623
+ guide: [
1624
+ {
1625
+ id: 'open',
1626
+ order: 30,
1627
+ title: () => (ctx.locale.bind ? ctx.locale.bind(NS)('title') : '手机'),
1628
+ description: () => (ctx.locale.bind ? ctx.locale.bind(NS)('desc') : ''),
1629
+ icon: PhoneGlyph,
1630
+ },
1631
+ ],
1632
+ }),
1633
+ 'dsh-phone: tab type',
1634
+ )
1635
+
1636
+ ctx.slots.inject('sidebar.right.pane.tab', () =>
1637
+ ctx.slots.register(
1638
+ {
1639
+ name: 'sidebar.right.pane.tab',
1640
+ key: 'dsh-phone',
1641
+ locale: NS,
1642
+ inject: () => ({
1643
+ floatTab: (tabId, rect) => ctx.sidebarRight.float(tabId, rect),
1644
+ }),
1645
+ },
1646
+ PhoneTabBody,
1647
+ ),
1648
+ )
1649
+ },
1650
+ }
1651
+ },
1652
+ })