@restregular/dsh-visual-browser 0.1.1 → 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/client.js CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Client half of dsh-visual-browser.
3
3
  *
4
- * One sidebar entry + one main panel: the live page frame, a click-through
5
- * surface, browser controls, and the replayable action timeline the Host keeps.
6
- * The whole file is an IIFE because every client bundle shares one global
7
- * lexical scope inside the page.
4
+ * A tab type for the right Sidebar: a live picture of the page Chrome is
5
+ * showing, a guide card under the built-in Browser entry that opens it, and a
6
+ * collapsible operation log. Pictures arrive as one long MJPEG response that the
7
+ * <img> element decodes natively; pointer, wheel and keyboard events go out in
8
+ * animation-frame batches, so a click costs one round trip instead of a
9
+ * screenshot. The whole file is an IIFE because every client bundle shares one
10
+ * global lexical scope inside the page.
8
11
  */
9
12
  (function () {
10
13
  'use strict';
@@ -12,10 +15,17 @@
12
15
  var MODULE_ID = '@restregular/dsh-visual-browser';
13
16
  var API = '/visual-browser/api';
14
17
  var NS = 'dsh-visual-browser';
18
+ var KIND = 'visual-browser';
19
+ var BROWSER_TYPE_ID = '@deepseek-ai/dsh-client-ui-sidebar-browser';
20
+ var BROWSER_KIND = 'browser';
15
21
 
16
22
  var DICT = {
17
23
  zh: {
18
- 'panel.title': '浏览器',
24
+ 'panel.title': '可视化浏览器',
25
+ 'panel.subtitle': '实时操作 Chrome',
26
+ 'guide.title': '可视化浏览器',
27
+ 'guide.description': '在这里实时操作 Chrome',
28
+ 'guide.open': '在可视化浏览器中打开',
19
29
  'state.running': '运行中',
20
30
  'state.starting': '启动中',
21
31
  'state.stopped': '未启动',
@@ -25,19 +35,21 @@
25
35
  'action.back': '后退',
26
36
  'action.forward': '前进',
27
37
  'action.reload': '刷新页面',
28
- 'action.capture': '截取画面',
29
- 'action.live': '实时画面',
30
38
  'action.open': '打开',
39
+ 'action.reconnect': '重新连接画面',
31
40
  'url.placeholder': '输入网址,例如 example.com',
32
- 'hint.interactive': '点击画面可直接操作浏览器,滚轮可滚动页面',
33
- 'hint.headless': '当前是无头浏览器:用户看不到窗口,画面只在这里',
41
+ 'hint.interactive': '直接用鼠标操作画面;点一下画面后可用键盘输入',
42
+ 'hint.headless': '无头浏览器:窗口不可见,画面只在这里',
34
43
  'empty.title': '浏览器还没启动',
35
- 'empty.desc': '在上方输入网址打开,或者让 Agent 用 browser_open 打开一个页面;之后每一次操作都会在这里留下画面。',
44
+ 'empty.desc': '在上方输入网址打开,或者让 Agent 用 browser_open 打开一个页面;之后每次操作都会在这里实时显示。',
45
+ 'connecting': '正在连接画面…',
36
46
  'timeline.title': '操作记录',
37
- 'timeline.empty': '还没有操作。Agent 每次点击、输入之后都会在这里留一帧画面。',
38
- 'timeline.latest': '回到最新',
39
- 'timeline.replay': '回放这一帧',
47
+ 'timeline.empty': '还没有操作。Agent 的点击、输入与你自己的操作都会记录在这里。',
48
+ 'timeline.count': '条',
49
+ 'timeline.expand': '展开操作记录',
50
+ 'timeline.collapse': '折叠操作记录',
40
51
  'error.offline': '连不上宿主路由(/visual-browser/api),请确认插件已启用且宿主仍在运行。',
52
+ 'error.frame': '画面流中断,正在重连…',
41
53
  'kind.start': '启动',
42
54
  'kind.close': '关闭',
43
55
  'kind.open': '打开',
@@ -55,12 +67,18 @@
55
67
  'kind.screenshot': '截图',
56
68
  'kind.dialog': '对话框',
57
69
  'kind.user-click': '你点击',
70
+ 'kind.user-drag': '你拖动',
58
71
  'kind.user-scroll': '你滚动',
59
72
  'kind.user-key': '你按键',
73
+ 'kind.user-type': '你输入',
60
74
  'busy': '处理中…',
61
75
  },
62
76
  en: {
63
- 'panel.title': 'Browser',
77
+ 'panel.title': 'Visual Browser',
78
+ 'panel.subtitle': 'Drive Chrome live',
79
+ 'guide.title': 'Visual Browser',
80
+ 'guide.description': 'Drive Chrome here, live',
81
+ 'guide.open': 'Open in the Visual Browser',
64
82
  'state.running': 'Running',
65
83
  'state.starting': 'Starting',
66
84
  'state.stopped': 'Stopped',
@@ -70,19 +88,21 @@
70
88
  'action.back': 'Back',
71
89
  'action.forward': 'Forward',
72
90
  'action.reload': 'Reload page',
73
- 'action.capture': 'Refresh frame',
74
- 'action.live': 'Live view',
75
91
  'action.open': 'Open',
92
+ 'action.reconnect': 'Reconnect the picture',
76
93
  'url.placeholder': 'Enter a URL, for example example.com',
77
- 'hint.interactive': 'Click the picture to control the browser; the wheel scrolls the page',
78
- 'hint.headless': 'Headless browser: there is no window to watch, the picture here is the browser',
94
+ 'hint.interactive': 'Click straight into the picture; click once to type with the keyboard',
95
+ 'hint.headless': 'Headless browser: no window to watch, the picture here is the browser',
79
96
  'empty.title': 'The browser is not running yet',
80
- 'empty.desc': 'Open a URL above, or let the Agent call browser_open. Every action it takes leaves a frame here.',
97
+ 'empty.desc': 'Open a URL above, or let the Agent call browser_open. Everything that happens then shows up here live.',
98
+ 'connecting': 'Connecting the picture…',
81
99
  'timeline.title': 'Actions',
82
- 'timeline.empty': 'No actions yet. Every click and keystroke the Agent makes leaves a frame here.',
83
- 'timeline.latest': 'Back to latest',
84
- 'timeline.replay': 'Replay this frame',
100
+ 'timeline.empty': 'No actions yet. Agent clicks, typing, and your own gestures are listed here.',
101
+ 'timeline.count': 'items',
102
+ 'timeline.expand': 'Expand the action log',
103
+ 'timeline.collapse': 'Collapse the action log',
85
104
  'error.offline': 'Cannot reach the host route (/visual-browser/api). Check that the plugin is enabled and the host is running.',
105
+ 'error.frame': 'The picture stream dropped; reconnecting…',
86
106
  'kind.start': 'start',
87
107
  'kind.close': 'close',
88
108
  'kind.open': 'open',
@@ -100,56 +120,78 @@
100
120
  'kind.screenshot': 'shot',
101
121
  'kind.dialog': 'dialog',
102
122
  'kind.user-click': 'you clicked',
123
+ 'kind.user-drag': 'you dragged',
103
124
  'kind.user-scroll': 'you scrolled',
104
125
  'kind.user-key': 'you pressed',
126
+ 'kind.user-type': 'you typed',
105
127
  'busy': 'Working…',
106
128
  },
107
129
  };
108
130
 
109
131
  var CSS = [
110
- '.dvb-page{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}',
111
- '.dvb-head{flex:none;border-bottom:1px solid var(--dsw-alias-border-l1);padding:10px 14px 10px;display:flex;flex-direction:column;gap:8px}',
112
- '.dvb-head-row{display:flex;align-items:center;gap:8px;min-width:0}',
113
- '.dvb-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--dsw-alias-state-idle-primary)}',
132
+ // ---- tab body -------------------------------------------------------
133
+ '.dvb-root{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;overflow:hidden;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}',
134
+ '.dvb-bar{flex:none;display:flex;align-items:center;gap:6px;padding:8px 10px 6px;min-width:0}',
135
+ '.dvb-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--dsw-alias-state-idle-primary,var(--dsw-alias-border-l3))}',
114
136
  '.dvb-dot[data-on="true"]{background:var(--dsw-alias-state-success-primary)}',
115
137
  '.dvb-dot[data-error="true"]{background:var(--dsw-alias-state-error-primary)}',
116
- '.dvb-title{font-weight:600;font-size:14px;white-space:nowrap}',
117
- '.dvb-status{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
118
- '.dvb-spacer{flex:1 1 auto;min-width:8px}',
119
- '.dvb-tools{display:flex;align-items:center;gap:4px;flex:none}',
120
- '.dvb-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:28px;min-width:28px;padding:0 8px;border-radius:7px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:12px}',
121
- '.dvb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}',
122
- '.dvb-btn:disabled{opacity:.45;cursor:default}',
123
- '.dvb-btn[data-on="true"]{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}',
124
- '.dvb-btn[data-variant="primary"]{background:var(--dsw-alias-brand-primary);border-color:transparent;color:#fff}',
125
- '.dvb-btn[data-variant="primary"]:hover:not(:disabled){background:var(--dsw-alias-brand-primary);filter:brightness(1.06);color:#fff}',
126
- '.dvb-url{display:flex;align-items:center;gap:8px;min-width:0}',
127
- '.dvb-input{flex:1 1 auto;min-width:0;height:28px;padding:0 10px;border-radius:7px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}',
128
- '.dvb-input::placeholder{color:var(--dsw-alias-label-secondary)}',
129
- '.dvb-input:focus{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-1px}',
130
- '.dvb-stage{flex:1 1 auto;min-height:0;overflow:auto;padding:12px 14px}',
131
- '.dvb-frame{display:block;width:100%;height:auto;border-radius:10px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);box-shadow:0 6px 20px rgba(0,0,0,.08)}',
132
- '.dvb-stage[data-interactive="true"] .dvb-frame{cursor:crosshair}',
133
- '.dvb-hint{padding:6px 2px 0;font-size:11px;color:var(--dsw-alias-label-secondary);display:flex;gap:10px;flex-wrap:wrap}',
134
- '.dvb-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;height:100%;min-height:220px;text-align:center;padding:24px}',
135
- '.dvb-empty-title{font-size:14px;font-weight:600}',
136
- '.dvb-empty-desc{max-width:360px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
137
- '.dvb-timeline{flex:none;border-top:1px solid var(--dsw-alias-border-l1);display:flex;flex-direction:column;min-height:0;max-height:38%}',
138
- '.dvb-timeline-head{display:flex;align-items:center;gap:8px;padding:8px 14px 6px;flex:none}',
139
- '.dvb-timeline-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
140
- '.dvb-rows{overflow:auto;padding:0 8px 10px;display:flex;flex-direction:column;gap:2px;min-height:0}',
141
- '.dvb-row{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:5px 6px;border-radius:6px;border:0;background:transparent;color:inherit;font:inherit;font-size:12px;cursor:pointer}',
142
- '.dvb-row:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}',
143
- '.dvb-row[data-selected="true"]{background:var(--dsw-alias-bg-layer-2);box-shadow:inset 0 0 0 1px var(--dsw-alias-brand-primary)}',
144
- '.dvb-row:disabled{cursor:default;opacity:.6}',
145
- '.dvb-row-time{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);flex:none;width:56px}',
146
- '.dvb-row-kind{flex:none;min-width:52px;padding:0 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;text-align:center}',
147
- '.dvb-row-kind[data-kind^="user"]{background:var(--dsw-alias-brand-primary);color:#fff}',
148
- '.dvb-row-text{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
138
+ '.dvb-spacer{flex:1 1 auto;min-width:6px}',
139
+ '.dvb-status{font-size:11.5px;color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
140
+ '.dvb-title{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}',
141
+ '.dvb-tools{display:flex;align-items:center;gap:2px;flex:none}',
142
+ '.dvb-btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;height:26px;min-width:26px;padding:0 7px;border-radius:7px;border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:12px}',
143
+ '.dvb-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
144
+ '.dvb-btn:disabled{opacity:.4;cursor:default}',
145
+ '.dvb-btn[data-variant="primary"]{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff);padding:0 10px}',
146
+ '.dvb-btn[data-variant="primary"]:hover:not(:disabled){background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff);filter:brightness(1.06)}',
147
+ '.dvb-addr{flex:none;display:flex;align-items:center;gap:6px;padding:0 10px 8px;min-width:0}',
148
+ '.dvb-input{flex:1 1 auto;min-width:0;height:28px;padding:0 9px;border-radius:7px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}',
149
+ '.dvb-input::placeholder{color:var(--dsw-alias-label-tertiary)}',
150
+ '.dvb-input:focus{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-1px}',
151
+ // ---- stage ----------------------------------------------------------
152
+ '.dvb-stage{position:relative;flex:1 1 auto;min-height:120px;overflow:hidden;display:flex;align-items:center;justify-content:center;padding:8px;background:var(--dsw-alias-bg-layer-1);border-top:1px solid var(--dsw-alias-border-l1);border-bottom:1px solid var(--dsw-alias-border-l1);outline:none}',
153
+ '.dvb-stage[data-focus="true"]{background:var(--dsw-alias-interactive-bg-hover)}',
154
+ '.dvb-live{display:block;max-width:100%;max-height:100%;width:auto;height:auto;border-radius:6px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);touch-action:none;user-select:none;-webkit-user-drag:none;cursor:default}',
155
+ '.dvb-live[data-busy="true"]{cursor:progress}',
156
+ '.dvb-cursor{position:absolute;left:0;top:0;width:18px;height:18px;margin:-9px 0 0 -9px;border-radius:50%;border:2px solid var(--dsw-alias-brand-primary);box-shadow:0 0 0 1px var(--dsw-alias-bg-base);pointer-events:none;opacity:0;will-change:transform}',
157
+ '.dvb-cursor[data-visible="true"]{opacity:.85}',
158
+ '.dvb-ripple{position:absolute;left:0;top:0;width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;background:var(--dsw-alias-brand-primary);opacity:0;pointer-events:none;will-change:transform,opacity}',
159
+ '.dvb-veil{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:8px;border:0;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 78%,transparent);cursor:pointer}',
160
+ '.dvb-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;text-align:center;padding:20px 24px;color:var(--dsw-alias-label-secondary)}',
161
+ '.dvb-empty-title{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary)}',
162
+ '.dvb-empty-desc{max-width:340px;font-size:12px}',
163
+ '.dvb-hint{padding:5px 10px 7px;font-size:11px;color:var(--dsw-alias-label-tertiary);display:flex;gap:8px;flex-wrap:wrap;flex:none}',
164
+ // ---- log ------------------------------------------------------------
165
+ '.dvb-log{flex:0 0 auto;display:flex;flex-direction:column;min-height:0;background:var(--dsw-alias-bg-base)}',
166
+ '.dvb-log-head{display:flex;align-items:center;gap:6px;width:100%;padding:7px 10px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;text-align:left;cursor:pointer}',
167
+ '.dvb-log-head:hover{background:var(--dsw-alias-interactive-bg-hover)}',
168
+ '.dvb-log-chevron{display:inline-flex;transition:transform .16s ease}',
169
+ '.dvb-log[data-open="true"] .dvb-log-chevron{transform:rotate(90deg)}',
170
+ '.dvb-log-title{font-weight:600;color:var(--dsw-alias-label-secondary)}',
171
+ '.dvb-log-count{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
172
+ '.dvb-log-body{overflow-y:auto;overscroll-behavior:contain;max-height:min(44vh,320px);min-height:0;padding:0 6px 8px;display:flex;flex-direction:column;gap:1px}',
173
+ '.dvb-log-empty{padding:3px 6px 10px;font-size:11.5px;color:var(--dsw-alias-label-tertiary)}',
174
+ '.dvb-row{display:flex;align-items:baseline;gap:6px;padding:4px 6px;border-radius:6px;font-size:12px;min-width:0}',
175
+ '.dvb-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
176
+ '.dvb-row-time{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);flex:none;width:52px;font-size:11px}',
177
+ '.dvb-row-kind{flex:none;padding:0 5px;border-radius:4px;border:.5px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:1.6;white-space:nowrap}',
178
+ '.dvb-row-kind[data-kind^="user"]{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff)}',
179
+ '.dvb-row-text{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
149
180
  '.dvb-row-text[data-error="true"]{color:var(--dsw-alias-state-error-primary)}',
150
- '.dvb-empty-timeline{padding:2px 14px 12px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
151
- '.dvb-error{margin:0 14px 10px;padding:8px 10px;border-radius:7px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-size:12px}',
152
- '.dvb-busy{font-size:11px;color:var(--dsw-alias-label-secondary)}',
181
+ '.dvb-error{margin:0 10px 8px;padding:6px 9px;border-radius:7px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-size:11.5px}',
182
+ // ---- guide cards ----------------------------------------------------
183
+ '.dvb-guide{display:flex;flex-direction:column;width:100%;box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-xl,16px);background:var(--dsw-alias-bg-layer-1);overflow:hidden}',
184
+ '.dvb-guide-card,.dvb-guide-extra{display:flex;align-items:center;gap:12px;width:100%;box-sizing:border-box;padding:12px 16px;border:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}',
185
+ '.dvb-guide-card{padding-bottom:10px}',
186
+ '.dvb-guide-extra{padding-top:10px;padding-bottom:11px}',
187
+ '.dvb-guide-card:hover,.dvb-guide-extra:hover{background:var(--dsw-alias-interactive-bg-hover)}',
188
+ '.dvb-guide-glyph{display:flex;align-items:center;justify-content:center;width:26px;height:26px;flex:none;color:var(--dsw-alias-label-secondary)}',
189
+ '.dvb-guide-extra-glyph{display:flex;align-items:center;justify-content:center;width:26px;height:26px;flex:none;border-radius:7px;background:var(--dsw-alias-state-business-tertiary,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary))}',
190
+ '.dvb-guide-text{display:flex;flex-direction:column;flex:1;min-width:0;gap:2px}',
191
+ '.dvb-guide-title{font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
192
+ '.dvb-guide-desc{font-size:10.5px;line-height:1.35;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
193
+ '.dvb-guide-sep{height:1px;margin:0 14px;background:var(--dsw-alias-border-l3)}',
194
+ '.dvb-guide-card>svg,.dvb-guide-extra>svg{color:var(--dsw-alias-label-tertiary);flex:none}',
153
195
  ].join('\n');
154
196
 
155
197
  window.__ModuleLoader__.load({
@@ -217,9 +259,9 @@
217
259
  }, 'dsh-visual-browser: styles');
218
260
  }
219
261
 
220
- // ---------------------------------------------------------------------
262
+ // -------------------------------------------------------------------
221
263
  // Host API
222
- // ---------------------------------------------------------------------
264
+ // -------------------------------------------------------------------
223
265
 
224
266
  async function requestJson(path, options) {
225
267
  var init = { method: (options && options.method) || 'GET', headers: {} };
@@ -254,92 +296,259 @@
254
296
  }
255
297
 
256
298
  function shortUrl(value) {
257
- var text = String(value || '');
258
- return text.replace(/^https?:\/\//, '').replace(/\/$/, '');
299
+ return String(value || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
259
300
  }
260
301
 
261
- // ---------------------------------------------------------------------
262
- // Icons (inline SVG, currentColor)
263
- // ---------------------------------------------------------------------
302
+ /**
303
+ * Input batches: everything the user does is queued and flushed once per
304
+ * animation frame as a single ordered POST. Moves and wheel deltas are
305
+ * folded together, so a fast flick is one request instead of thirty.
306
+ */
307
+ function createSender() {
308
+ var queue = [];
309
+ var inFlight = false;
310
+ var frame = 0;
311
+ var disposed = false;
264
312
 
265
- function icon(name, size) {
266
- var common = {
267
- width: size || 16,
268
- height: size || 16,
269
- viewBox: '0 0 24 24',
270
- fill: 'none',
271
- stroke: 'currentColor',
272
- strokeWidth: 1.9,
273
- strokeLinecap: 'round',
274
- strokeLinejoin: 'round',
275
- 'aria-hidden': true,
276
- focusable: false,
313
+ var compact = function (events) {
314
+ var kept = [];
315
+ for (var index = 0; index < events.length; index += 1) {
316
+ var event = events[index];
317
+ var last = kept.length > 0 ? kept[kept.length - 1] : null;
318
+ if (last !== null && event.t === 'pointer' && event.type === 'move' && last.t === 'pointer' && last.type === 'move') {
319
+ kept[kept.length - 1] = event;
320
+ continue;
321
+ }
322
+ if (last !== null && event.t === 'wheel' && last.t === 'wheel') {
323
+ last.dx = (last.dx || 0) + (event.dx || 0);
324
+ last.dy = (last.dy || 0) + (event.dy || 0);
325
+ last.x = event.x;
326
+ last.y = event.y;
327
+ continue;
328
+ }
329
+ kept.push(event);
330
+ }
331
+ return kept.slice(0, 120);
332
+ };
333
+
334
+ var flush = function () {
335
+ frame = 0;
336
+ if (disposed || inFlight) return;
337
+ if (queue.length === 0) return;
338
+ var batch = compact(queue);
339
+ queue = [];
340
+ if (batch.length === 0) return;
341
+ inFlight = true;
342
+ fetch(API + '/input', {
343
+ method: 'POST',
344
+ headers: { 'content-type': 'application/json' },
345
+ body: JSON.stringify({ events: batch }),
346
+ keepalive: true,
347
+ })
348
+ .catch(function () {
349
+ /* a dropped input batch is not worth a visible error */
350
+ })
351
+ .then(function () {
352
+ inFlight = false;
353
+ if (disposed) return;
354
+ if (queue.length > 0 && frame === 0) frame = window.requestAnimationFrame(flush);
355
+ });
356
+ };
357
+
358
+ return {
359
+ send: function (event) {
360
+ if (disposed) return;
361
+ queue.push(event);
362
+ if (frame !== 0) return;
363
+ frame = window.requestAnimationFrame(flush);
364
+ },
365
+ flushNow: function () {
366
+ if (frame !== 0) {
367
+ window.cancelAnimationFrame(frame);
368
+ frame = 0;
369
+ }
370
+ flush();
371
+ },
372
+ dispose: function () {
373
+ disposed = true;
374
+ queue = [];
375
+ if (frame !== 0) window.cancelAnimationFrame(frame);
376
+ frame = 0;
377
+ },
277
378
  };
278
- var paths;
279
- switch (name) {
280
- case 'play':
281
- paths = [h('path', { key: 'a', d: 'M8 5.5v13l11-6.5-11-6.5Z', fill: 'currentColor', stroke: 'none' })];
282
- break;
283
- case 'stop':
284
- paths = [h('rect', { key: 'a', x: 6, y: 6, width: 12, height: 12, rx: 2.5, fill: 'currentColor', stroke: 'none' })];
285
- break;
286
- case 'back':
287
- paths = [h('path', { key: 'a', d: 'M14.5 6.5 8 12l6.5 5.5' })];
288
- break;
289
- case 'forward':
290
- paths = [h('path', { key: 'a', d: 'M9.5 6.5 16 12l-6.5 5.5' })];
291
- break;
292
- case 'reload':
293
- paths = [
294
- h('path', { key: 'a', d: 'M19 12a7 7 0 1 1-2.1-5' }),
295
- h('path', { key: 'b', d: 'M19 4.5V9h-4.5' }),
296
- ];
297
- break;
298
- case 'camera':
299
- paths = [
300
- h('path', { key: 'a', d: 'M4.5 8.5h3l1.5-2h6l1.5 2h3v9.5h-15Z' }),
301
- h('circle', { key: 'b', cx: 12, cy: 13, r: 3.2 }),
302
- ];
303
- break;
304
- case 'live':
305
- paths = [
306
- h('circle', { key: 'a', cx: 12, cy: 12, r: 3 }),
307
- h('path', { key: 'b', d: 'M6.5 6.5a7.8 7.8 0 0 0 0 11M17.5 6.5a7.8 7.8 0 0 1 0 11' }),
308
- ];
309
- break;
310
- default:
311
- paths = [];
312
- }
313
- return h('svg', common, paths);
314
379
  }
315
380
 
316
- function PanelIcon(props) {
317
- var size = (props && props.size) || 16;
318
- var active = props && props.active;
381
+ // -------------------------------------------------------------------
382
+ // Icons
383
+ // -------------------------------------------------------------------
384
+
385
+ function svg(props, paths) {
319
386
  return h(
320
387
  'svg',
321
388
  {
322
- width: size,
323
- height: size,
389
+ width: props.size || 16,
390
+ height: props.size || 16,
324
391
  viewBox: '0 0 24 24',
325
392
  fill: 'none',
326
393
  stroke: 'currentColor',
327
- strokeWidth: 1.8,
394
+ strokeWidth: props.strokeWidth || 1.9,
328
395
  strokeLinecap: 'round',
329
396
  strokeLinejoin: 'round',
330
397
  'aria-hidden': true,
331
398
  focusable: false,
332
- style: { opacity: active ? 1 : 0.86 },
333
399
  },
400
+ paths,
401
+ );
402
+ }
403
+
404
+ function icon(name, size) {
405
+ switch (name) {
406
+ case 'play':
407
+ return svg({ size: size }, [h('path', { key: 'a', d: 'M8 5.5v13l11-6.5-11-6.5Z', fill: 'currentColor', stroke: 'none' })]);
408
+ case 'stop':
409
+ return svg({ size: size }, [h('rect', { key: 'a', x: 6, y: 6, width: 12, height: 12, rx: 2.5, fill: 'currentColor', stroke: 'none' })]);
410
+ case 'back':
411
+ return svg({ size: size }, [h('path', { key: 'a', d: 'M14.5 6.5 8 12l6.5 5.5' })]);
412
+ case 'forward':
413
+ return svg({ size: size }, [h('path', { key: 'a', d: 'M9.5 6.5 16 12l-6.5 5.5' })]);
414
+ case 'reload':
415
+ return svg({ size: size }, [
416
+ h('path', { key: 'a', d: 'M19 12a7 7 0 1 1-2.1-5' }),
417
+ h('path', { key: 'b', d: 'M19 4.5V9h-4.5' }),
418
+ ]);
419
+ case 'chevron':
420
+ return svg({ size: size || 12 }, [h('path', { key: 'a', d: 'M9.5 6l6 6-6 6' })]);
421
+ case 'globe':
422
+ return svg({ size: size || 24, strokeWidth: 1.6 }, [
423
+ h('circle', { key: 'a', cx: 12, cy: 12, r: 8.5 }),
424
+ h('path', { key: 'b', d: 'M3.5 12h17' }),
425
+ h('path', { key: 'c', d: 'M12 3.5c2.6 2.4 4 5.4 4 8.5s-1.4 6.1-4 8.5c-2.6-2.4-4-5.4-4-8.5s1.4-6.1 4-8.5Z' }),
426
+ ]);
427
+ default:
428
+ return svg({ size: size }, []);
429
+ }
430
+ }
431
+
432
+ function BrowserGlyph(props) {
433
+ return svg({ size: (props && props.size) || 18, strokeWidth: 1.8 }, [
334
434
  h('rect', { key: 'a', x: 3, y: 4.5, width: 18, height: 15, rx: 2.6 }),
335
435
  h('path', { key: 'b', d: 'M3 8.5h18' }),
336
436
  h('circle', { key: 'c', cx: 12, cy: 13.6, r: 2.2, fill: 'currentColor', stroke: 'none' }),
437
+ ]);
438
+ }
439
+
440
+ // -------------------------------------------------------------------
441
+ // Guide cards
442
+ // -------------------------------------------------------------------
443
+
444
+ /** The right-Sidebar controller, kept in step by `apply` for guide cards. */
445
+ var sidebarRightOpen = null;
446
+
447
+ function openTabFrom(info, kind) {
448
+ if (info && info.tab && info.tab.actions && typeof info.tab.actions.openTab === 'function') {
449
+ try {
450
+ info.tab.actions.openTab(kind, { replaceTab: true });
451
+ return;
452
+ } catch (error) {
453
+ console.warn('[dsh-visual-browser] tab.actions.openTab failed:', error && error.message ? error.message : error);
454
+ }
455
+ }
456
+ if (typeof sidebarRightOpen === 'function') {
457
+ try {
458
+ sidebarRightOpen(kind, { replaceTab: true });
459
+ return;
460
+ } catch (error) {
461
+ console.warn('[dsh-visual-browser] sidebarRight.openTab failed:', error && error.message ? error.message : error);
462
+ return;
463
+ }
464
+ }
465
+ console.warn('[dsh-visual-browser] no way to open tab', kind);
466
+ }
467
+
468
+ /** The card that carries our own entry when the built-in Browser is absent. */
469
+ function VisualBrowserGuideCard(props) {
470
+ var t = props.t || translateCurrent;
471
+ var info = typeof props.useTabInfo === 'function' ? props.useTabInfo() : null;
472
+ return h(
473
+ 'div',
474
+ { className: 'dvb-guide', 'data-dvb-guide': 'own' },
475
+ h(
476
+ 'button',
477
+ {
478
+ type: 'button',
479
+ className: 'dvb-guide-card',
480
+ 'data-sidebar-right-guide-entry': props.kind || KIND,
481
+ onClick: function () {
482
+ openTabFrom(info, props.kind || KIND);
483
+ },
484
+ },
485
+ h('span', { className: 'dvb-guide-glyph' }, h(BrowserGlyph, { size: 24 })),
486
+ h(
487
+ 'span',
488
+ { className: 'dvb-guide-text' },
489
+ h('span', { className: 'dvb-guide-title' }, props.title || t('guide.title')),
490
+ h('span', { className: 'dvb-guide-desc' }, props.description || t('guide.description')),
491
+ ),
492
+ icon('chevron', 14),
493
+ ),
494
+ );
495
+ }
496
+
497
+ /**
498
+ * The built-in Browser card, with one extra row under it: this is what
499
+ * puts a door to the Visual Browser directly beneath the system browser.
500
+ */
501
+ function BrowserExtraGuideCard(props) {
502
+ var t = props.t || translateCurrent;
503
+ var info = typeof props.useTabInfo === 'function' ? props.useTabInfo() : null;
504
+ return h(
505
+ 'div',
506
+ { className: 'dvb-guide', 'data-dvb-guide': 'browser' },
507
+ h(
508
+ 'button',
509
+ {
510
+ type: 'button',
511
+ className: 'dvb-guide-card',
512
+ 'data-sidebar-right-guide-entry': props.kind || BROWSER_KIND,
513
+ onClick: function () {
514
+ openTabFrom(info, props.kind || BROWSER_KIND);
515
+ },
516
+ },
517
+ h('span', { className: 'dvb-guide-glyph' }, icon('globe', 24)),
518
+ h(
519
+ 'span',
520
+ { className: 'dvb-guide-text' },
521
+ h('span', { className: 'dvb-guide-title' }, props.title || t('panel.title')),
522
+ props.description ? h('span', { className: 'dvb-guide-desc' }, props.description) : null,
523
+ ),
524
+ icon('chevron', 14),
525
+ ),
526
+ h('div', { className: 'dvb-guide-sep', role: 'none' }),
527
+ h(
528
+ 'button',
529
+ {
530
+ type: 'button',
531
+ className: 'dvb-guide-extra',
532
+ title: t('guide.open'),
533
+ onClick: function () {
534
+ openTabFrom(info, KIND);
535
+ },
536
+ },
537
+ h('span', { className: 'dvb-guide-extra-glyph' }, h(BrowserGlyph, { size: 16 })),
538
+ h(
539
+ 'span',
540
+ { className: 'dvb-guide-text' },
541
+ h('span', { className: 'dvb-guide-title' }, t('guide.title')),
542
+ h('span', { className: 'dvb-guide-desc' }, t('guide.description')),
543
+ ),
544
+ icon('chevron', 14),
545
+ ),
337
546
  );
338
547
  }
339
548
 
340
- // ---------------------------------------------------------------------
341
- // Panel
342
- // ---------------------------------------------------------------------
549
+ // -------------------------------------------------------------------
550
+ // Tab body
551
+ // -------------------------------------------------------------------
343
552
 
344
553
  var EMPTY_STATE = {
345
554
  running: false,
@@ -347,30 +556,64 @@
347
556
  url: '',
348
557
  title: '',
349
558
  viewport: { width: 1280, height: 800 },
350
- frameSeq: 0,
559
+ startedAt: 0,
351
560
  actionSeq: 0,
352
- frameSeqs: [],
353
561
  actions: [],
354
562
  allowUserControl: true,
355
563
  lastError: '',
356
564
  };
357
565
 
358
- function BrowserPage(props) {
566
+ var MODIFIER_MASK = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
567
+
568
+ function modifierMask(event) {
569
+ var mask = 0;
570
+ if (event.altKey) mask |= MODIFIER_MASK.Alt;
571
+ if (event.ctrlKey) mask |= MODIFIER_MASK.Control;
572
+ if (event.metaKey) mask |= MODIFIER_MASK.Meta;
573
+ if (event.shiftKey) mask |= MODIFIER_MASK.Shift;
574
+ return mask;
575
+ }
576
+
577
+ function typedText(event) {
578
+ if (event.ctrlKey || event.metaKey || event.altKey) return '';
579
+ if (typeof event.key !== 'string' || event.key.length !== 1) return '';
580
+ return event.key;
581
+ }
582
+
583
+ function BrowserTab(props) {
359
584
  var ctx = props.ctx;
360
- var t = translateCurrent;
585
+ var t = props.t || translateCurrent;
361
586
  var [tick, setTick] = useState(0);
362
587
  var [state, setState] = useState(EMPTY_STATE);
363
588
  var [reachable, setReachable] = useState(true);
364
589
  var [busy, setBusy] = useState('');
365
590
  var [error, setError] = useState('');
366
- var [live, setLive] = useState(false);
367
- var [liveTick, setLiveTick] = useState(0);
368
- var [previewSeq, setPreviewSeq] = useState(0);
369
591
  var [draft, setDraft] = useState('');
370
592
  var [dirty, setDirty] = useState(false);
593
+ var [logOpen, setLogOpen] = useState(true);
594
+ var [live, setLive] = useState(false);
595
+ var [streamRetry, setStreamRetry] = useState(0);
596
+ var [retrying, setRetrying] = useState(false);
597
+ var [focused, setFocused] = useState(false);
598
+
371
599
  var stageRef = useRef(null);
372
- var wheelAcc = useRef({ deltaY: 0, timer: 0 });
600
+ var canvasRef = useRef(null);
601
+ var liveRef = useRef(false);
602
+ var cursorRef = useRef(null);
603
+ var rippleRef = useRef(null);
604
+ var pointerRef = useRef({ down: false, id: 0, x: 0, y: 0, moved: 0, clicks: 1, lastUpAt: 0, lastUpX: 0, lastUpY: 0, button: 0 });
605
+ var stateJsonRef = useRef('');
606
+ var sender = useMemo(function () {
607
+ return createSender();
608
+ }, []);
373
609
 
610
+ useEffect(function () {
611
+ return function () {
612
+ sender.dispose();
613
+ };
614
+ }, [sender]);
615
+
616
+ // Re-render on language change only.
374
617
  useEffect(function () {
375
618
  var locale = ctx.get('locale');
376
619
  if (!locale || typeof locale.subscribe !== 'function') return undefined;
@@ -381,6 +624,7 @@
381
624
  });
382
625
  }, [ctx]);
383
626
 
627
+ // State poll: identity-compare the JSON so an idle panel never re-renders.
384
628
  useEffect(
385
629
  function () {
386
630
  var cancelled = false;
@@ -390,12 +634,18 @@
390
634
  var data = await requestJson('/state');
391
635
  if (cancelled) return;
392
636
  setReachable(true);
393
- if (data.state) setState(data.state);
637
+ if (data.state) {
638
+ var next = JSON.stringify(data.state);
639
+ if (next !== stateJsonRef.current) {
640
+ stateJsonRef.current = next;
641
+ setState(data.state);
642
+ }
643
+ }
394
644
  } catch {
395
645
  if (!cancelled) setReachable(false);
396
646
  }
397
647
  if (!cancelled) {
398
- timer = window.setTimeout(poll, document.visibilityState === 'hidden' ? 5000 : 1500);
648
+ timer = window.setTimeout(poll, document.visibilityState === 'hidden' ? 4000 : 900);
399
649
  }
400
650
  };
401
651
  poll();
@@ -414,97 +664,409 @@
414
664
  [state.url, dirty],
415
665
  );
416
666
 
667
+ // A new browser incarnation means a new stream.
668
+ useEffect(
669
+ function () {
670
+ liveRef.current = false;
671
+ setLive(false);
672
+ setRetrying(false);
673
+ },
674
+ [state.startedAt, state.running],
675
+ );
676
+
677
+ /**
678
+ * The live picture: one long binary response, each frame a length-
679
+ * prefixed JPEG decoded off the main thread and painted on a canvas.
680
+ * Decoding keeps at most one frame queued, so a burst never builds a
681
+ * backlog of stale pictures.
682
+ */
417
683
  useEffect(
418
684
  function () {
419
- if (!live || !state.running) return undefined;
420
- var id = window.setInterval(function () {
421
- setLiveTick(function (value) {
422
- return value + 1;
685
+ if (!state.running) return undefined;
686
+ var cancelled = false;
687
+ var controller = new AbortController();
688
+ var reader = null;
689
+ var buffer = new Uint8Array(0);
690
+ var pendingFrame = null;
691
+ var decoding = false;
692
+
693
+ var draw = async function (jpeg) {
694
+ var bitmap = await createImageBitmap(new Blob([jpeg], { type: 'image/jpeg' }));
695
+ var canvas = canvasRef.current;
696
+ if (canvas !== null) {
697
+ if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
698
+ canvas.width = bitmap.width;
699
+ canvas.height = bitmap.height;
700
+ }
701
+ var context = canvas.getContext('2d');
702
+ if (context) context.drawImage(bitmap, 0, 0);
703
+ if (!liveRef.current) {
704
+ liveRef.current = true;
705
+ setLive(true);
706
+ setRetrying(false);
707
+ }
708
+ }
709
+ bitmap.close();
710
+ };
711
+
712
+ var drain = async function () {
713
+ if (decoding) return;
714
+ decoding = true;
715
+ try {
716
+ while (!cancelled && pendingFrame !== null) {
717
+ var jpeg = pendingFrame;
718
+ pendingFrame = null;
719
+ try {
720
+ await draw(jpeg);
721
+ } catch {
722
+ /* a frame that will not decode is skipped */
723
+ }
724
+ }
725
+ } finally {
726
+ decoding = false;
727
+ }
728
+ };
729
+
730
+ var pump = async function () {
731
+ var response = await fetch(API + '/stream?r=' + streamRetry, {
732
+ signal: controller.signal,
733
+ cache: 'no-store',
423
734
  });
424
- }, 1100);
735
+ if (!response.ok || !response.body) throw new Error('stream ' + response.status);
736
+ reader = response.body.getReader();
737
+ for (;;) {
738
+ var step = await reader.read();
739
+ if (step.done) break;
740
+ var chunk = step.value;
741
+ var joined = new Uint8Array(buffer.length + chunk.length);
742
+ joined.set(buffer, 0);
743
+ joined.set(chunk, buffer.length);
744
+ buffer = joined;
745
+ while (buffer.length >= 8) {
746
+ var view = new DataView(buffer.buffer, buffer.byteOffset, 8);
747
+ var length = view.getUint32(0, true);
748
+ if (length === 0 || length > 8 * 1024 * 1024) {
749
+ buffer = new Uint8Array(0);
750
+ break;
751
+ }
752
+ if (buffer.length < 8 + length) break;
753
+ pendingFrame = buffer.slice(8, 8 + length);
754
+ buffer = buffer.slice(8 + length);
755
+ void drain();
756
+ }
757
+ }
758
+ throw new Error('stream ended');
759
+ };
760
+
761
+ pump().catch(function () {
762
+ if (cancelled) return;
763
+ liveRef.current = false;
764
+ setLive(false);
765
+ setRetrying(true);
766
+ window.setTimeout(function () {
767
+ if (!cancelled) {
768
+ setStreamRetry(function (value) {
769
+ return value + 1;
770
+ });
771
+ }
772
+ }, Math.min(4000, 600 + streamRetry * 400));
773
+ });
774
+
425
775
  return function () {
426
- window.clearInterval(id);
776
+ cancelled = true;
777
+ controller.abort();
778
+ try {
779
+ if (reader) void reader.cancel();
780
+ } catch {
781
+ /* already closed */
782
+ }
427
783
  };
428
784
  },
429
- [live, state.running],
785
+ [state.running, state.startedAt, streamRetry],
430
786
  );
431
787
 
432
- var run = useCallback(async function (label, path, body) {
433
- setBusy(label);
434
- setError('');
788
+ var refreshState = useCallback(async function () {
435
789
  try {
436
- var data = await requestJson(path, { method: 'POST', body: body || {} });
437
- if (data && data.state) {
790
+ var data = await requestJson('/state');
791
+ setReachable(true);
792
+ if (data.state) {
793
+ stateJsonRef.current = JSON.stringify(data.state);
438
794
  setState(data.state);
439
- setReachable(true);
440
795
  }
441
- } catch (failure) {
442
- setError(failure && failure.message ? failure.message : String(failure));
443
- } finally {
444
- setBusy('');
796
+ } catch {
797
+ setReachable(false);
445
798
  }
446
799
  }, []);
447
800
 
448
- var sendWheel = useCallback(
449
- function (deltaY) {
450
- if (!state.running || !state.allowUserControl) return;
451
- void run('wheel', '/wheel', { deltaY: Math.round(deltaY) });
801
+ var run = useCallback(
802
+ async function (label, path, body) {
803
+ setBusy(label);
804
+ setError('');
805
+ try {
806
+ var data = await requestJson(path, { method: 'POST', body: body || {} });
807
+ if (data && data.state) {
808
+ stateJsonRef.current = JSON.stringify(data.state);
809
+ setState(data.state);
810
+ setReachable(true);
811
+ }
812
+ } catch (failure) {
813
+ setError(failure && failure.message ? failure.message : String(failure));
814
+ } finally {
815
+ setBusy('');
816
+ }
452
817
  },
453
- [run, state.running, state.allowUserControl],
818
+ [],
454
819
  );
455
820
 
821
+ // Geometry of the picture in CSS pixels, read on demand (never cached).
822
+ var frameRect = function () {
823
+ var canvas = canvasRef.current;
824
+ if (!canvas) return null;
825
+ var rect = canvas.getBoundingClientRect();
826
+ if (!rect || rect.width < 2 || rect.height < 2) return null;
827
+ return rect;
828
+ };
829
+
830
+ var toNormalized = function (event) {
831
+ var rect = frameRect();
832
+ if (!rect) return null;
833
+ var x = (event.clientX - rect.left) / rect.width;
834
+ var y = (event.clientY - rect.top) / rect.height;
835
+ if (x < -0.02 || x > 1.02 || y < -0.02 || y > 1.02) return null;
836
+ return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };
837
+ };
838
+
839
+ var placeCursor = function (event) {
840
+ var stage = stageRef.current;
841
+ var ring = cursorRef.current;
842
+ if (!stage || !ring) return;
843
+ var stageRect = stage.getBoundingClientRect();
844
+ var x = event.clientX - stageRect.left;
845
+ var y = event.clientY - stageRect.top;
846
+ ring.style.transform = 'translate3d(' + x + 'px,' + y + 'px,0)';
847
+ ring.setAttribute('data-visible', 'true');
848
+ };
849
+
850
+ var hideCursor = function () {
851
+ var ring = cursorRef.current;
852
+ if (ring) ring.setAttribute('data-visible', 'false');
853
+ };
854
+
855
+ var ripple = function (event) {
856
+ var stage = stageRef.current;
857
+ var mark = rippleRef.current;
858
+ if (!stage || !mark || typeof mark.animate !== 'function') return;
859
+ var stageRect = stage.getBoundingClientRect();
860
+ var x = event.clientX - stageRect.left;
861
+ var y = event.clientY - stageRect.top;
862
+ try {
863
+ mark.animate(
864
+ [
865
+ { transform: 'translate3d(' + x + 'px,' + y + 'px,0) scale(.35)', opacity: 0.5 },
866
+ { transform: 'translate3d(' + x + 'px,' + y + 'px,0) scale(2.1)', opacity: 0 },
867
+ ],
868
+ { duration: 340, easing: 'ease-out' },
869
+ );
870
+ } catch {
871
+ /* older engines simply skip the ripple */
872
+ }
873
+ };
874
+
875
+ var clickCountFor = function (point) {
876
+ var pointer = pointerRef.current;
877
+ var now = Date.now();
878
+ var rect = frameRect();
879
+ var width = rect ? rect.width : 1;
880
+ var height = rect ? rect.height : 1;
881
+ var near =
882
+ Math.abs(point.x - pointer.lastUpX) * width < 6 && Math.abs(point.y - pointer.lastUpY) * height < 6;
883
+ var count = now - pointer.lastUpAt < 420 && near ? 2 : 1;
884
+ pointer.lastUpAt = now;
885
+ pointer.lastUpX = point.x;
886
+ pointer.lastUpY = point.y;
887
+ return count;
888
+ };
889
+
890
+ var sendFlush = function () {
891
+ // Gesture boundaries are worth an immediate flush; a held-down drag is not.
892
+ sender.flushNow();
893
+ window.setTimeout(function () {
894
+ void refreshState();
895
+ }, 220);
896
+ };
897
+
898
+ var onPointerDown = function (event) {
899
+ if (!state.running || !state.allowUserControl) return;
900
+ if (event.button !== 0 && event.button !== 2 && event.button !== 1) return;
901
+ var point = toNormalized(event);
902
+ if (!point) return;
903
+ event.preventDefault();
904
+ var stage = stageRef.current;
905
+ if (stage && typeof stage.focus === 'function') {
906
+ try {
907
+ stage.focus({ preventScroll: true });
908
+ } catch {
909
+ /* focus on an old engine */
910
+ }
911
+ }
912
+ var pointer = pointerRef.current;
913
+ pointer.down = true;
914
+ pointer.id = event.pointerId;
915
+ pointer.x = point.x;
916
+ pointer.y = point.y;
917
+ pointer.moved = 0;
918
+ pointer.button = event.button;
919
+ pointer.clicks = pointer.button === 0 ? clickCountFor(point) : 1;
920
+ try {
921
+ if (event.currentTarget.setPointerCapture) event.currentTarget.setPointerCapture(event.pointerId);
922
+ } catch {
923
+ /* capture is a nicety, not a requirement */
924
+ }
925
+ placeCursor(event);
926
+ ripple(event);
927
+ var masked = modifierMask(event);
928
+ sender.send({ t: 'pointer', type: 'move', x: point.x, y: point.y, buttons: event.buttons || 0, modifiers: masked });
929
+ sender.send({
930
+ t: 'pointer',
931
+ type: 'down',
932
+ x: point.x,
933
+ y: point.y,
934
+ button: event.button,
935
+ buttons: event.buttons || 1,
936
+ clicks: pointer.clicks,
937
+ modifiers: masked,
938
+ });
939
+ };
940
+
941
+ var onPointerMove = function (event) {
942
+ if (!state.running || !state.allowUserControl) return;
943
+ placeCursor(event);
944
+ var point = toNormalized(event);
945
+ if (!point) return;
946
+ var pointer = pointerRef.current;
947
+ if (!pointer.down) {
948
+ // Hover matters for menus and tooltips; folding happens in the sender.
949
+ sender.send({ t: 'pointer', type: 'move', x: point.x, y: point.y, buttons: 0, modifiers: modifierMask(event) });
950
+ return;
951
+ }
952
+ var rect = frameRect();
953
+ if (rect) {
954
+ var dx = (point.x - pointer.x) * rect.width;
955
+ var dy = (point.y - pointer.y) * rect.height;
956
+ pointer.moved = Math.max(pointer.moved, Math.sqrt(dx * dx + dy * dy));
957
+ }
958
+ sender.send({ t: 'pointer', type: 'move', x: point.x, y: point.y, buttons: event.buttons || 0, modifiers: modifierMask(event) });
959
+ };
960
+
961
+ var onPointerUp = function (event) {
962
+ var pointer = pointerRef.current;
963
+ if (!pointer.down) return;
964
+ if (!state.running || !state.allowUserControl) {
965
+ pointer.down = false;
966
+ return;
967
+ }
968
+ pointer.down = false;
969
+ try {
970
+ if (event.currentTarget.releasePointerCapture) event.currentTarget.releasePointerCapture(event.pointerId);
971
+ } catch {
972
+ /* already released */
973
+ }
974
+ var point = toNormalized(event) || { x: pointer.x, y: pointer.y };
975
+ var masked = modifierMask(event);
976
+ var viewportWidth = state.viewport && state.viewport.width ? state.viewport.width : 1280;
977
+ var viewportHeight = state.viewport && state.viewport.height ? state.viewport.height : 800;
978
+ var travel = Math.sqrt(
979
+ Math.pow((point.x - pointer.x) * viewportWidth, 2) + Math.pow((point.y - pointer.y) * viewportHeight, 2),
980
+ );
981
+ var drag = Math.max(pointer.moved, travel) > 4;
982
+ sender.send({ t: 'pointer', type: 'move', x: point.x, y: point.y, buttons: 0, modifiers: masked });
983
+ sender.send({
984
+ t: 'pointer',
985
+ type: 'up',
986
+ x: point.x,
987
+ y: point.y,
988
+ button: pointer.button,
989
+ buttons: 0,
990
+ clicks: pointer.clicks || 1,
991
+ drag: drag,
992
+ modifiers: masked,
993
+ });
994
+ sendFlush();
995
+ };
996
+
997
+ // Native wheel listener: React's is passive in places, and the picture
998
+ // must never scroll the sidebar around it.
456
999
  useEffect(
457
1000
  function () {
458
- var stage = stageRef.current;
459
- if (!stage) return undefined;
1001
+ var canvas = canvasRef.current;
1002
+ if (!canvas) return undefined;
460
1003
  var onWheel = function (event) {
461
1004
  if (!state.running || !state.allowUserControl) return;
462
1005
  event.preventDefault();
463
- var acc = wheelAcc.current;
464
- acc.deltaY += event.deltaY;
465
- if (acc.timer) window.clearTimeout(acc.timer);
466
- acc.timer = window.setTimeout(function () {
467
- var deltaY = acc.deltaY;
468
- acc.deltaY = 0;
469
- acc.timer = 0;
470
- if (Math.abs(deltaY) >= 1) sendWheel(deltaY);
471
- }, 160);
1006
+ var rect = frameRect();
1007
+ if (!rect) return;
1008
+ var x = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
1009
+ var y = Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height));
1010
+ var scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? rect.height : 1;
1011
+ sender.send({
1012
+ t: 'wheel',
1013
+ x: x,
1014
+ y: y,
1015
+ dx: Math.round(event.deltaX * scale),
1016
+ dy: Math.round(event.deltaY * scale),
1017
+ modifiers: modifierMask(event),
1018
+ });
472
1019
  };
473
- stage.addEventListener('wheel', onWheel, { passive: false });
1020
+ canvas.addEventListener('wheel', onWheel, { passive: false });
474
1021
  return function () {
475
- stage.removeEventListener('wheel', onWheel);
476
- var acc = wheelAcc.current;
477
- if (acc.timer) window.clearTimeout(acc.timer);
1022
+ canvas.removeEventListener('wheel', onWheel);
478
1023
  };
479
1024
  },
480
- [sendWheel, state.running, state.allowUserControl],
1025
+ [sender, state.running, state.allowUserControl, live],
481
1026
  );
482
1027
 
483
- var onFrameClick = function (event) {
1028
+ var onKeyDown = function (event) {
484
1029
  if (!state.running || !state.allowUserControl) return;
485
- var image = event.currentTarget;
486
- var rect = image.getBoundingClientRect();
487
- if (!rect || rect.width < 2 || rect.height < 2) return;
488
- var x = (event.clientX - rect.left) / rect.width;
489
- var y = (event.clientY - rect.top) / rect.height;
490
- if (x < 0 || x > 1 || y < 0 || y > 1) return;
491
- void run('click', '/click', { x: x, y: y });
1030
+ var text = typedText(event);
1031
+ if (text === '' && (event.ctrlKey || event.metaKey) && event.key === 'v') {
1032
+ return; // paste is handled by onPaste with real clipboard text
1033
+ }
1034
+ event.preventDefault();
1035
+ sender.send({
1036
+ t: 'key',
1037
+ type: 'down',
1038
+ key: event.key,
1039
+ code: event.code,
1040
+ keyCode: event.keyCode,
1041
+ text: text,
1042
+ repeat: event.repeat === true,
1043
+ modifiers: modifierMask(event),
1044
+ });
1045
+ sender.flushNow();
492
1046
  };
493
1047
 
494
- var frameSrc = useMemo(
495
- function () {
496
- if (previewSeq > 0) return API + '/frame?seq=' + previewSeq;
497
- if (live && state.running) return API + '/live?t=' + liveTick;
498
- return API + '/frame?seq=' + (state.frameSeq || 0);
499
- },
500
- [previewSeq, live, liveTick, state.frameSeq, state.running],
501
- );
1048
+ var onKeyUp = function (event) {
1049
+ if (!state.running || !state.allowUserControl) return;
1050
+ event.preventDefault();
1051
+ sender.send({
1052
+ t: 'key',
1053
+ type: 'up',
1054
+ key: event.key,
1055
+ code: event.code,
1056
+ keyCode: event.keyCode,
1057
+ modifiers: modifierMask(event),
1058
+ });
1059
+ sender.flushNow();
1060
+ };
502
1061
 
503
- var hasFrame = previewSeq > 0 || (state.running && state.frameSeq > 0);
504
- var kindLabel = function (kind) {
505
- var key = 'kind.' + kind;
506
- var text = t(key);
507
- return text === key ? kind : text;
1062
+ var onPaste = function (event) {
1063
+ if (!state.running || !state.allowUserControl) return;
1064
+ var data = event.clipboardData;
1065
+ var text = data ? data.getData('text/plain') : '';
1066
+ if (!text) return;
1067
+ event.preventDefault();
1068
+ sender.send({ t: 'text', text: text });
1069
+ sender.flushNow();
508
1070
  };
509
1071
 
510
1072
  var openOrStart = function () {
@@ -512,6 +1074,19 @@
512
1074
  setDirty(false);
513
1075
  };
514
1076
 
1077
+ var retryStream = function () {
1078
+ setRetrying(true);
1079
+ setStreamRetry(function (value) {
1080
+ return value + 1;
1081
+ });
1082
+ };
1083
+
1084
+ var kindLabel = function (kind) {
1085
+ var key = 'kind.' + kind;
1086
+ var text = t(key);
1087
+ return text === key ? kind : text;
1088
+ };
1089
+
515
1090
  var statusText = !reachable
516
1091
  ? t('state.offline')
517
1092
  : state.starting
@@ -520,275 +1095,269 @@
520
1095
  ? t('state.running')
521
1096
  : t('state.stopped');
522
1097
 
1098
+ var actions = state.actions || [];
1099
+
523
1100
  return h(
524
1101
  'div',
525
- { className: 'dvb-page' },
1102
+ { className: 'dvb-root' },
526
1103
  h(
527
1104
  'div',
528
- { className: 'dvb-head' },
1105
+ { className: 'dvb-bar' },
1106
+ h('span', {
1107
+ className: 'dvb-dot',
1108
+ 'data-on': String(state.running),
1109
+ 'data-error': String(!reachable),
1110
+ title: statusText + (state.headless ? ' · headless' : ''),
1111
+ }),
529
1112
  h(
530
- 'div',
531
- { className: 'dvb-head-row' },
532
- h('span', {
533
- className: 'dvb-dot',
534
- 'data-on': String(state.running),
535
- 'data-error': String(!reachable),
536
- title: statusText + (state.headless ? ' · headless' : ''),
537
- }),
538
- h('span', { className: 'dvb-title' }, t('panel.title')),
539
- h('span', { className: 'dvb-status', title: state.title || state.url || '' }, state.title || shortUrl(state.url) || statusText),
540
- h('span', { className: 'dvb-spacer' }),
541
- busy ? h('span', { className: 'dvb-busy' }, t('busy')) : null,
542
- h(
543
- 'div',
544
- { className: 'dvb-tools' },
545
- h(
546
- 'button',
547
- {
548
- type: 'button',
549
- className: 'dvb-btn',
550
- title: t('action.back'),
551
- 'aria-label': t('action.back'),
552
- disabled: !state.running || Boolean(busy),
553
- onClick: function () {
554
- void run('back', '/nav', { action: 'back' });
555
- },
556
- },
557
- icon('back'),
558
- ),
559
- h(
560
- 'button',
561
- {
562
- type: 'button',
563
- className: 'dvb-btn',
564
- title: t('action.forward'),
565
- 'aria-label': t('action.forward'),
566
- disabled: !state.running || Boolean(busy),
567
- onClick: function () {
568
- void run('forward', '/nav', { action: 'forward' });
569
- },
570
- },
571
- icon('forward'),
572
- ),
573
- h(
574
- 'button',
575
- {
576
- type: 'button',
577
- className: 'dvb-btn',
578
- title: t('action.reload'),
579
- 'aria-label': t('action.reload'),
580
- disabled: !state.running || Boolean(busy),
581
- onClick: function () {
582
- void run('reload', '/nav', { action: 'reload' });
583
- },
584
- },
585
- icon('reload'),
586
- ),
587
- h(
588
- 'button',
589
- {
590
- type: 'button',
591
- className: 'dvb-btn',
592
- title: t('action.live'),
593
- 'aria-label': t('action.live'),
594
- 'data-on': String(live),
595
- disabled: !state.running || Boolean(busy),
596
- onClick: function () {
597
- setLive(function (value) {
598
- return !value;
599
- });
600
- setLiveTick(function (value) {
601
- return value + 1;
602
- });
603
- },
604
- },
605
- icon('live'),
606
- ),
607
- h(
608
- 'button',
609
- {
610
- type: 'button',
611
- className: 'dvb-btn',
612
- title: t('action.capture'),
613
- 'aria-label': t('action.capture'),
614
- disabled: !state.running || Boolean(busy),
615
- onClick: function () {
616
- void run('capture', '/capture', {});
617
- },
618
- },
619
- icon('camera'),
620
- ),
621
- state.running
622
- ? h(
623
- 'button',
624
- {
625
- type: 'button',
626
- className: 'dvb-btn',
627
- title: t('action.stop'),
628
- 'aria-label': t('action.stop'),
629
- disabled: Boolean(busy),
630
- onClick: function () {
631
- void run('stop', '/stop', {});
632
- },
633
- },
634
- icon('stop'),
635
- )
636
- : h(
637
- 'button',
638
- {
639
- type: 'button',
640
- className: 'dvb-btn',
641
- 'data-variant': 'primary',
642
- disabled: Boolean(busy),
643
- onClick: openOrStart,
644
- },
645
- icon('play'),
646
- t('action.start'),
647
- ),
648
- ),
1113
+ 'span',
1114
+ { className: 'dvb-title', title: state.title || state.url || t('panel.title') },
1115
+ state.title || shortUrl(state.url) || t('panel.title'),
649
1116
  ),
1117
+ h('span', { className: 'dvb-spacer' }),
1118
+ busy ? h('span', { className: 'dvb-status' }, t('busy')) : null,
650
1119
  h(
651
1120
  'div',
652
- { className: 'dvb-url' },
653
- h('input', {
654
- className: 'dvb-input',
655
- type: 'text',
656
- spellCheck: false,
657
- placeholder: t('url.placeholder'),
658
- value: draft,
659
- onChange: function (event) {
660
- setDirty(true);
661
- setDraft(event.target.value);
1121
+ { className: 'dvb-tools' },
1122
+ h(
1123
+ 'button',
1124
+ {
1125
+ type: 'button',
1126
+ className: 'dvb-btn',
1127
+ title: t('action.back'),
1128
+ 'aria-label': t('action.back'),
1129
+ disabled: !state.running || Boolean(busy),
1130
+ onClick: function () {
1131
+ void run('back', '/nav', { action: 'back' });
1132
+ },
662
1133
  },
663
- onKeyDown: function (event) {
664
- if (event.key === 'Enter') {
665
- event.preventDefault();
666
- openOrStart();
667
- }
1134
+ icon('back', 16),
1135
+ ),
1136
+ h(
1137
+ 'button',
1138
+ {
1139
+ type: 'button',
1140
+ className: 'dvb-btn',
1141
+ title: t('action.forward'),
1142
+ 'aria-label': t('action.forward'),
1143
+ disabled: !state.running || Boolean(busy),
1144
+ onClick: function () {
1145
+ void run('forward', '/nav', { action: 'forward' });
1146
+ },
668
1147
  },
669
- }),
1148
+ icon('forward', 16),
1149
+ ),
670
1150
  h(
671
1151
  'button',
672
1152
  {
673
1153
  type: 'button',
674
1154
  className: 'dvb-btn',
675
- disabled: Boolean(busy) || draft.trim() === '',
676
- onClick: openOrStart,
1155
+ title: t('action.reload'),
1156
+ 'aria-label': t('action.reload'),
1157
+ disabled: !state.running || Boolean(busy),
1158
+ onClick: function () {
1159
+ void run('reload', '/nav', { action: 'reload' });
1160
+ },
677
1161
  },
678
- t('action.open'),
1162
+ icon('reload', 16),
679
1163
  ),
1164
+ state.running
1165
+ ? h(
1166
+ 'button',
1167
+ {
1168
+ type: 'button',
1169
+ className: 'dvb-btn',
1170
+ title: t('action.stop'),
1171
+ 'aria-label': t('action.stop'),
1172
+ disabled: Boolean(busy),
1173
+ onClick: function () {
1174
+ void run('stop', '/stop', {});
1175
+ },
1176
+ },
1177
+ icon('stop', 16),
1178
+ )
1179
+ : h(
1180
+ 'button',
1181
+ {
1182
+ type: 'button',
1183
+ className: 'dvb-btn',
1184
+ 'data-variant': 'primary',
1185
+ disabled: Boolean(busy),
1186
+ onClick: openOrStart,
1187
+ },
1188
+ icon('play', 14),
1189
+ t('action.start'),
1190
+ ),
1191
+ ),
1192
+ ),
1193
+ h(
1194
+ 'div',
1195
+ { className: 'dvb-addr' },
1196
+ h('input', {
1197
+ className: 'dvb-input',
1198
+ type: 'text',
1199
+ spellCheck: false,
1200
+ placeholder: t('url.placeholder'),
1201
+ value: draft,
1202
+ onChange: function (event) {
1203
+ setDirty(true);
1204
+ setDraft(event.target.value);
1205
+ },
1206
+ onKeyDown: function (event) {
1207
+ if (event.key === 'Enter') {
1208
+ event.preventDefault();
1209
+ openOrStart();
1210
+ }
1211
+ },
1212
+ }),
1213
+ h(
1214
+ 'button',
1215
+ {
1216
+ type: 'button',
1217
+ className: 'dvb-btn',
1218
+ disabled: Boolean(busy) || draft.trim() === '',
1219
+ onClick: openOrStart,
1220
+ },
1221
+ t('action.open'),
680
1222
  ),
681
1223
  ),
682
1224
  error ? h('div', { className: 'dvb-error' }, error) : null,
683
1225
  !reachable ? h('div', { className: 'dvb-error' }, t('error.offline')) : null,
684
1226
  h(
685
1227
  'div',
686
- { className: 'dvb-stage', ref: stageRef, 'data-interactive': String(state.running && state.allowUserControl) },
687
- hasFrame
688
- ? h(
689
- 'div',
690
- null,
691
- h('img', {
692
- className: 'dvb-frame',
693
- src: frameSrc,
694
- alt: state.title || state.url || t('panel.title'),
695
- draggable: false,
696
- onClick: onFrameClick,
697
- }),
698
- h(
699
- 'div',
700
- { className: 'dvb-hint' },
701
- state.running ? h('span', null, state.allowUserControl ? t('hint.interactive') : '—') : null,
702
- state.running && state.headless ? h('span', null, t('hint.headless')) : null,
703
- ),
704
- )
1228
+ {
1229
+ className: 'dvb-stage',
1230
+ ref: stageRef,
1231
+ tabIndex: 0,
1232
+ 'data-focus': String(focused),
1233
+ onFocus: function () {
1234
+ setFocused(true);
1235
+ },
1236
+ onBlur: function () {
1237
+ setFocused(false);
1238
+ },
1239
+ onKeyDown: onKeyDown,
1240
+ onKeyUp: onKeyUp,
1241
+ onPaste: onPaste,
1242
+ onContextMenu: function (event) {
1243
+ event.preventDefault();
1244
+ },
1245
+ },
1246
+ state.running
1247
+ ? h('canvas', {
1248
+ ref: canvasRef,
1249
+ className: 'dvb-live',
1250
+ width: state.viewport && state.viewport.width ? state.viewport.width : 1280,
1251
+ height: state.viewport && state.viewport.height ? state.viewport.height : 800,
1252
+ role: 'img',
1253
+ 'aria-label': state.title || state.url || t('panel.title'),
1254
+ 'data-busy': String(!live),
1255
+ onPointerDown: onPointerDown,
1256
+ onPointerMove: onPointerMove,
1257
+ onPointerUp: onPointerUp,
1258
+ onPointerCancel: onPointerUp,
1259
+ onPointerLeave: hideCursor,
1260
+ onContextMenu: function (event) {
1261
+ event.preventDefault();
1262
+ },
1263
+ onDragStart: function (event) {
1264
+ event.preventDefault();
1265
+ },
1266
+ })
705
1267
  : h(
706
1268
  'div',
707
1269
  { className: 'dvb-empty' },
708
- h('svg', {
709
- width: 56,
710
- height: 56,
711
- viewBox: '0 0 24 24',
712
- fill: 'none',
713
- stroke: 'var(--dsw-alias-label-secondary)',
714
- strokeWidth: 1.2,
715
- 'aria-hidden': true,
716
- },
717
- h('rect', { x: 2.5, y: 4, width: 19, height: 16, rx: 2.5 }),
718
- h('path', { d: 'M2.5 8h19' }),
719
- h('circle', { cx: 12, cy: 13.5, r: 2.6 }),
720
- ),
1270
+ h(BrowserGlyph, { size: 44 }),
721
1271
  h('div', { className: 'dvb-empty-title' }, t('empty.title')),
722
1272
  h('div', { className: 'dvb-empty-desc' }, t('empty.desc')),
723
- state.running
724
- ? h('button', {
725
- type: 'button',
726
- className: 'dvb-btn',
727
- disabled: Boolean(busy),
728
- onClick: function () {
729
- void run('capture', '/capture', {});
730
- },
731
- }, t('action.capture'))
732
- : null,
733
1273
  ),
1274
+ state.running && !live
1275
+ ? h(
1276
+ 'button',
1277
+ {
1278
+ type: 'button',
1279
+ className: 'dvb-veil',
1280
+ onClick: retryStream,
1281
+ title: t('action.reconnect'),
1282
+ },
1283
+ t(retrying ? 'error.frame' : 'connecting'),
1284
+ )
1285
+ : null,
1286
+ h('span', { className: 'dvb-cursor', ref: cursorRef, 'data-visible': 'false' }),
1287
+ h('span', { className: 'dvb-ripple', ref: rippleRef }),
734
1288
  ),
1289
+ state.running && state.allowUserControl
1290
+ ? h(
1291
+ 'div',
1292
+ { className: 'dvb-hint' },
1293
+ h('span', null, t('hint.interactive')),
1294
+ state.headless ? h('span', null, t('hint.headless')) : null,
1295
+ )
1296
+ : null,
735
1297
  h(
736
1298
  'div',
737
- { className: 'dvb-timeline' },
1299
+ { className: 'dvb-log', 'data-open': String(logOpen) },
738
1300
  h(
739
- 'div',
740
- { className: 'dvb-timeline-head' },
741
- h('span', { className: 'dvb-timeline-title' }, t('timeline.title')),
742
- h('span', { className: 'dvb-status' }, state.actions.length ? String(state.actions.length) : ''),
1301
+ 'button',
1302
+ {
1303
+ type: 'button',
1304
+ className: 'dvb-log-head',
1305
+ title: logOpen ? t('timeline.collapse') : t('timeline.expand'),
1306
+ 'aria-expanded': String(logOpen),
1307
+ onClick: function () {
1308
+ setLogOpen(function (value) {
1309
+ return !value;
1310
+ });
1311
+ },
1312
+ },
1313
+ h('span', { className: 'dvb-log-chevron' }, icon('chevron', 12)),
1314
+ h('span', { className: 'dvb-log-title' }, t('timeline.title')),
1315
+ h('span', { className: 'dvb-log-count' }, actions.length > 0 ? String(actions.length) : ''),
743
1316
  h('span', { className: 'dvb-spacer' }),
744
- previewSeq > 0
745
- ? h(
746
- 'button',
747
- {
748
- type: 'button',
749
- className: 'dvb-btn',
750
- onClick: function () {
751
- setPreviewSeq(0);
752
- },
753
- },
754
- t('timeline.latest'),
755
- )
756
- : null,
1317
+ reachable ? null : h('span', { className: 'dvb-status' }, t('state.offline')),
757
1318
  ),
758
- state.actions.length === 0
759
- ? h('div', { className: 'dvb-empty-timeline' }, t('timeline.empty'))
760
- : h(
761
- 'div',
762
- { className: 'dvb-rows' },
763
- state.actions.map(function (action) {
764
- var selected = previewSeq > 0 && previewSeq === action.frameSeq;
765
- var label = action.detail || action.target || kindLabel(action.kind);
766
- return h(
767
- 'button',
768
- {
769
- key: action.seq,
770
- type: 'button',
771
- className: 'dvb-row',
772
- 'data-selected': String(selected),
773
- disabled: !action.frameSeq,
774
- title: label,
775
- onClick: function () {
776
- if (action.frameSeq) setPreviewSeq(action.frameSeq);
777
- },
778
- },
779
- h('span', { className: 'dvb-row-time' }, clock(action.ts)),
780
- h('span', { className: 'dvb-row-kind', 'data-kind': action.kind }, kindLabel(action.kind)),
781
- h('span', { className: 'dvb-row-text', 'data-error': String(action.ok === false) }, label),
782
- );
783
- }),
784
- ),
1319
+ logOpen
1320
+ ? actions.length === 0
1321
+ ? h('div', { className: 'dvb-log-empty' }, t('timeline.empty'))
1322
+ : h(
1323
+ 'div',
1324
+ { className: 'dvb-log-body' },
1325
+ actions.map(function (action) {
1326
+ var label = action.detail || action.target || kindLabel(action.kind);
1327
+ return h(
1328
+ 'div',
1329
+ { key: action.seq, className: 'dvb-row', title: label },
1330
+ h('span', { className: 'dvb-row-time' }, clock(action.ts)),
1331
+ h('span', { className: 'dvb-row-kind', 'data-kind': action.kind }, kindLabel(action.kind)),
1332
+ h('span', { className: 'dvb-row-text', 'data-error': String(action.ok === false) }, label),
1333
+ );
1334
+ }),
1335
+ )
1336
+ : null,
785
1337
  ),
786
1338
  );
787
1339
  }
788
1340
 
789
- // ---------------------------------------------------------------------
1341
+ // -------------------------------------------------------------------
1342
+ // Tab title
1343
+ // -------------------------------------------------------------------
1344
+
1345
+ function BrowserTabTitle(props) {
1346
+ var t = props.t || translateCurrent;
1347
+ var info = null;
1348
+ if (typeof props.useTabInfo === 'function') info = props.useTabInfo();
1349
+ var title = info && info.tab && info.tab.title ? info.tab.title : t('panel.title');
1350
+ return h(
1351
+ 'span',
1352
+ { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
1353
+ h(BrowserGlyph, { size: 15 }),
1354
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, title),
1355
+ );
1356
+ }
1357
+
1358
+ // -------------------------------------------------------------------
790
1359
  // Module
791
- // ---------------------------------------------------------------------
1360
+ // -------------------------------------------------------------------
792
1361
 
793
1362
  return {
794
1363
  inject: ['slots', 'locale'],
@@ -800,6 +1369,7 @@
800
1369
  console.warn('[dsh-visual-browser] slots service missing; panel disabled');
801
1370
  return;
802
1371
  }
1372
+
803
1373
  var register = function (slot, options, component, label) {
804
1374
  try {
805
1375
  ctx.effect(function () {
@@ -811,23 +1381,93 @@
811
1381
  console.warn('[dsh-visual-browser] could not register ' + slot + ':', error && error.message ? error.message : error);
812
1382
  }
813
1383
  };
814
- register(
815
- 'sidebar.panellist',
816
- {
817
- id: 'visual-browser',
818
- order: 40,
819
- label: function () {
820
- return translateCurrent('panel.title');
1384
+
1385
+ // A guide card may open our tab through `tab.actions`, but the
1386
+ // controller keeps working from any seat; hold it for the fallback.
1387
+ ctx.inject(['sidebarRight'], function (scope) {
1388
+ var service = scope.get('sidebarRight');
1389
+ if (!service || typeof service.openTab !== 'function') return;
1390
+ var open = service.openTab.bind(service);
1391
+ sidebarRightOpen = open;
1392
+ scope.effect(
1393
+ function () {
1394
+ return function () {
1395
+ if (sidebarRightOpen === open) sidebarRightOpen = null;
1396
+ };
1397
+ },
1398
+ 'dsh-visual-browser: sidebar controller handle',
1399
+ );
1400
+ });
1401
+
1402
+ // The tab type waits for the sidebar-right registry: sampling it once
1403
+ // in apply leaves the type unregistered on a cold page boot.
1404
+ ctx.inject(['sidebarRightTabs'], function (scope) {
1405
+ var tabs = scope.get('sidebarRightTabs');
1406
+ if (!tabs || typeof tabs.register !== 'function') {
1407
+ console.warn('[dsh-visual-browser] sidebarRightTabs service missing; tab type not registered');
1408
+ return;
1409
+ }
1410
+ scope.effect(
1411
+ function () {
1412
+ return tabs.register({
1413
+ id: MODULE_ID,
1414
+ kind: KIND,
1415
+ priority: 'extension',
1416
+ title: function () {
1417
+ return translateCurrent('panel.title');
1418
+ },
1419
+ guide: [
1420
+ {
1421
+ id: 'open',
1422
+ order: 40,
1423
+ title: function () {
1424
+ return translateCurrent('guide.title');
1425
+ },
1426
+ description: function () {
1427
+ return translateCurrent('guide.description');
1428
+ },
1429
+ },
1430
+ ],
1431
+ });
821
1432
  },
1433
+ 'dsh-visual-browser: tab type',
1434
+ );
1435
+ });
1436
+
1437
+ register(
1438
+ 'sidebar.right.pane.tab',
1439
+ { key: MODULE_ID, locale: NS },
1440
+ function (props) {
1441
+ return h(BrowserTab, { ctx: ctx, t: props.t });
822
1442
  },
1443
+ 'dsh-visual-browser: tab body',
1444
+ );
1445
+ register(
1446
+ 'sidebar.right.pane.tab.title',
1447
+ { key: MODULE_ID, locale: NS },
1448
+ BrowserTabTitle,
1449
+ 'dsh-visual-browser: tab title',
1450
+ );
1451
+ // Our own card only stands in when the built-in Browser has no card
1452
+ // for us to extend; normally the Browser card carries the extra row.
1453
+ register(
1454
+ 'sidebar.right.tab.guide.entry',
1455
+ { key: MODULE_ID, locale: NS },
823
1456
  function (props) {
824
- return h(PanelIcon, { size: props && props.size ? props.size : 16, active: Boolean(props && props.active) });
1457
+ var tabs = ctx.get('sidebarRightTabs');
1458
+ var browserPresent =
1459
+ tabs !== undefined && typeof tabs.get === 'function' && tabs.get(BROWSER_KIND) !== undefined;
1460
+ if (browserPresent) return null;
1461
+ return h(VisualBrowserGuideCard, props);
825
1462
  },
826
- 'dsh-visual-browser: sidebar entry',
1463
+ 'dsh-visual-browser: own guide card',
1464
+ );
1465
+ register(
1466
+ 'sidebar.right.tab.guide.entry',
1467
+ { key: BROWSER_TYPE_ID, locale: NS },
1468
+ BrowserExtraGuideCard,
1469
+ 'dsh-visual-browser: browser guide card',
827
1470
  );
828
- register('main', { key: 'visual-browser' }, function () {
829
- return h(BrowserPage, { ctx: ctx });
830
- }, 'dsh-visual-browser: main page');
831
1471
  },
832
1472
  };
833
1473
  },