@restregular/dsh-visual-browser 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,22 @@
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.resize': '上下拖动调整操作记录高度,双击复位',
51
+ 'timeline.collapse': '折叠操作记录',
40
52
  'error.offline': '连不上宿主路由(/visual-browser/api),请确认插件已启用且宿主仍在运行。',
53
+ 'error.frame': '画面流中断,正在重连…',
41
54
  'kind.start': '启动',
42
55
  'kind.close': '关闭',
43
56
  'kind.open': '打开',
@@ -55,12 +68,18 @@
55
68
  'kind.screenshot': '截图',
56
69
  'kind.dialog': '对话框',
57
70
  'kind.user-click': '你点击',
71
+ 'kind.user-drag': '你拖动',
58
72
  'kind.user-scroll': '你滚动',
59
73
  'kind.user-key': '你按键',
74
+ 'kind.user-type': '你输入',
60
75
  'busy': '处理中…',
61
76
  },
62
77
  en: {
63
- 'panel.title': 'Browser',
78
+ 'panel.title': 'Visual Browser',
79
+ 'panel.subtitle': 'Drive Chrome live',
80
+ 'guide.title': 'Visual Browser',
81
+ 'guide.description': 'Drive Chrome here, live',
82
+ 'guide.open': 'Open in the Visual Browser',
64
83
  'state.running': 'Running',
65
84
  'state.starting': 'Starting',
66
85
  'state.stopped': 'Stopped',
@@ -70,19 +89,22 @@
70
89
  'action.back': 'Back',
71
90
  'action.forward': 'Forward',
72
91
  'action.reload': 'Reload page',
73
- 'action.capture': 'Refresh frame',
74
- 'action.live': 'Live view',
75
92
  'action.open': 'Open',
93
+ 'action.reconnect': 'Reconnect the picture',
76
94
  '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',
95
+ 'hint.interactive': 'Click straight into the picture; click once to type with the keyboard',
96
+ 'hint.headless': 'Headless browser: no window to watch, the picture here is the browser',
79
97
  '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.',
98
+ 'empty.desc': 'Open a URL above, or let the Agent call browser_open. Everything that happens then shows up here live.',
99
+ 'connecting': 'Connecting the picture…',
81
100
  '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',
101
+ 'timeline.empty': 'No actions yet. Agent clicks, typing, and your own gestures are listed here.',
102
+ 'timeline.count': 'items',
103
+ 'timeline.expand': 'Expand the action log',
104
+ 'timeline.resize': 'Drag up or down to resize the action log; double-click to reset',
105
+ 'timeline.collapse': 'Collapse the action log',
85
106
  'error.offline': 'Cannot reach the host route (/visual-browser/api). Check that the plugin is enabled and the host is running.',
107
+ 'error.frame': 'The picture stream dropped; reconnecting…',
86
108
  'kind.start': 'start',
87
109
  'kind.close': 'close',
88
110
  'kind.open': 'open',
@@ -100,56 +122,83 @@
100
122
  'kind.screenshot': 'shot',
101
123
  'kind.dialog': 'dialog',
102
124
  'kind.user-click': 'you clicked',
125
+ 'kind.user-drag': 'you dragged',
103
126
  'kind.user-scroll': 'you scrolled',
104
127
  'kind.user-key': 'you pressed',
128
+ 'kind.user-type': 'you typed',
105
129
  'busy': 'Working…',
106
130
  },
107
131
  };
108
132
 
109
133
  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)}',
134
+ // ---- tab body -------------------------------------------------------
135
+ '.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}',
136
+ '.dvb-bar{flex:none;display:flex;align-items:center;gap:6px;padding:8px 10px 6px;min-width:0}',
137
+ '.dvb-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--dsw-alias-state-idle-primary,var(--dsw-alias-border-l3))}',
114
138
  '.dvb-dot[data-on="true"]{background:var(--dsw-alias-state-success-primary)}',
115
139
  '.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}',
140
+ '.dvb-spacer{flex:1 1 auto;min-width:6px}',
141
+ '.dvb-status{font-size:11.5px;color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
142
+ '.dvb-title{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}',
143
+ '.dvb-tools{display:flex;align-items:center;gap:2px;flex:none}',
144
+ '.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}',
145
+ '.dvb-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
146
+ '.dvb-btn:disabled{opacity:.4;cursor:default}',
147
+ '.dvb-btn[data-variant="primary"]{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff);padding:0 10px}',
148
+ '.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)}',
149
+ '.dvb-addr{flex:none;display:flex;align-items:center;gap:6px;padding:0 10px 8px;min-width:0}',
150
+ '.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}',
151
+ '.dvb-input::placeholder{color:var(--dsw-alias-label-tertiary)}',
152
+ '.dvb-input:focus{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-1px}',
153
+ // ---- stage ----------------------------------------------------------
154
+ '.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}',
155
+ '.dvb-stage[data-focus="true"]{background:var(--dsw-alias-interactive-bg-hover)}',
156
+ '.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}',
157
+ '.dvb-live[data-busy="true"]{cursor:progress}',
158
+ '.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}',
159
+ '.dvb-cursor[data-visible="true"]{opacity:.85}',
160
+ '.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}',
161
+ '.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}',
162
+ '.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)}',
163
+ '.dvb-empty-title{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary)}',
164
+ '.dvb-empty-desc{max-width:340px;font-size:12px}',
165
+ '.dvb-hint{padding:5px 10px 7px;font-size:11px;color:var(--dsw-alias-label-tertiary);display:flex;gap:8px;flex-wrap:wrap;flex:none}',
166
+ // ---- log ------------------------------------------------------------
167
+ '.dvb-log{flex:0 0 auto;display:flex;flex-direction:column;min-height:0;background:var(--dsw-alias-bg-base)}',
168
+ '.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}',
169
+ '.dvb-log-head:hover{background:var(--dsw-alias-interactive-bg-hover)}',
170
+ '.dvb-log-chevron{display:inline-flex;transition:transform .16s ease}',
171
+ '.dvb-log[data-open="true"] .dvb-log-chevron{transform:rotate(90deg)}',
172
+ '.dvb-log-title{font-weight:600;color:var(--dsw-alias-label-secondary)}',
173
+ '.dvb-log-count{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
174
+ '.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}',
175
+ '.dvb-log[data-sized="true"] .dvb-log-body{max-height:none;height:var(--dvb-log-h,200px)}',
176
+ '.dvb-split{flex:none;display:flex;align-items:center;justify-content:center;height:11px;margin:-1px 0;padding:0;border:0;background:transparent;cursor:ns-resize;touch-action:none}',
177
+ '.dvb-split::before{content:"";width:36px;height:3px;border-radius:2px;background:var(--dsw-alias-border-l2);transition:background .12s ease}',
178
+ '.dvb-split:hover::before,.dvb-split[data-active="true"]::before{background:var(--dsw-alias-brand-primary)}',
179
+ '.dvb-split:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-3px;border-radius:6px}',
180
+ '.dvb-log-empty{padding:3px 6px 10px;font-size:11.5px;color:var(--dsw-alias-label-tertiary)}',
181
+ '.dvb-row{display:flex;align-items:baseline;gap:6px;padding:4px 6px;border-radius:6px;font-size:12px;min-width:0}',
182
+ '.dvb-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
183
+ '.dvb-row-time{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);flex:none;width:52px;font-size:11px}',
184
+ '.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}',
185
+ '.dvb-row-kind[data-kind^="user"]{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff)}',
186
+ '.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
187
  '.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)}',
188
+ '.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}',
189
+ // ---- guide cards ----------------------------------------------------
190
+ '.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}',
191
+ '.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}',
192
+ '.dvb-guide-card{padding-bottom:10px}',
193
+ '.dvb-guide-extra{padding-top:10px;padding-bottom:11px}',
194
+ '.dvb-guide-card:hover,.dvb-guide-extra:hover{background:var(--dsw-alias-interactive-bg-hover)}',
195
+ '.dvb-guide-glyph{display:flex;align-items:center;justify-content:center;width:26px;height:26px;flex:none;color:var(--dsw-alias-label-secondary)}',
196
+ '.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))}',
197
+ '.dvb-guide-text{display:flex;flex-direction:column;flex:1;min-width:0;gap:2px}',
198
+ '.dvb-guide-title{font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
199
+ '.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}',
200
+ '.dvb-guide-sep{height:1px;margin:0 14px;background:var(--dsw-alias-border-l3)}',
201
+ '.dvb-guide-card>svg,.dvb-guide-extra>svg{color:var(--dsw-alias-label-tertiary);flex:none}',
153
202
  ].join('\n');
154
203
 
155
204
  window.__ModuleLoader__.load({
@@ -217,9 +266,9 @@
217
266
  }, 'dsh-visual-browser: styles');
218
267
  }
219
268
 
220
- // ---------------------------------------------------------------------
269
+ // -------------------------------------------------------------------
221
270
  // Host API
222
- // ---------------------------------------------------------------------
271
+ // -------------------------------------------------------------------
223
272
 
224
273
  async function requestJson(path, options) {
225
274
  var init = { method: (options && options.method) || 'GET', headers: {} };
@@ -254,92 +303,287 @@
254
303
  }
255
304
 
256
305
  function shortUrl(value) {
257
- var text = String(value || '');
258
- return text.replace(/^https?:\/\//, '').replace(/\/$/, '');
306
+ return String(value || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
259
307
  }
260
308
 
261
- // ---------------------------------------------------------------------
262
- // Icons (inline SVG, currentColor)
263
- // ---------------------------------------------------------------------
309
+ /**
310
+ * Input batches: everything the user does is queued and flushed once per
311
+ * animation frame as a single ordered POST. Moves and wheel deltas are
312
+ * folded together, so a fast flick is one request instead of thirty.
313
+ */
314
+ function createSender() {
315
+ var queue = [];
316
+ var inFlight = false;
317
+ var frame = 0;
318
+ var disposed = false;
264
319
 
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,
320
+ var compact = function (events) {
321
+ var kept = [];
322
+ for (var index = 0; index < events.length; index += 1) {
323
+ var event = events[index];
324
+ var last = kept.length > 0 ? kept[kept.length - 1] : null;
325
+ if (last !== null && event.t === 'pointer' && event.type === 'move' && last.t === 'pointer' && last.type === 'move') {
326
+ kept[kept.length - 1] = event;
327
+ continue;
328
+ }
329
+ if (last !== null && event.t === 'wheel' && last.t === 'wheel') {
330
+ last.dx = (last.dx || 0) + (event.dx || 0);
331
+ last.dy = (last.dy || 0) + (event.dy || 0);
332
+ last.x = event.x;
333
+ last.y = event.y;
334
+ continue;
335
+ }
336
+ kept.push(event);
337
+ }
338
+ return kept.slice(0, 120);
339
+ };
340
+
341
+ var flush = function () {
342
+ frame = 0;
343
+ if (disposed || inFlight) return;
344
+ if (queue.length === 0) return;
345
+ var batch = compact(queue);
346
+ queue = [];
347
+ if (batch.length === 0) return;
348
+ inFlight = true;
349
+ fetch(API + '/input', {
350
+ method: 'POST',
351
+ headers: { 'content-type': 'application/json' },
352
+ body: JSON.stringify({ events: batch }),
353
+ keepalive: true,
354
+ })
355
+ .catch(function () {
356
+ /* a dropped input batch is not worth a visible error */
357
+ })
358
+ .then(function () {
359
+ inFlight = false;
360
+ if (disposed) return;
361
+ if (queue.length > 0 && frame === 0) frame = window.requestAnimationFrame(flush);
362
+ });
363
+ };
364
+
365
+ return {
366
+ send: function (event) {
367
+ if (disposed) return;
368
+ queue.push(event);
369
+ if (frame !== 0) return;
370
+ frame = window.requestAnimationFrame(flush);
371
+ },
372
+ flushNow: function () {
373
+ if (frame !== 0) {
374
+ window.cancelAnimationFrame(frame);
375
+ frame = 0;
376
+ }
377
+ flush();
378
+ },
379
+ dispose: function () {
380
+ disposed = true;
381
+ queue = [];
382
+ if (frame !== 0) window.cancelAnimationFrame(frame);
383
+ frame = 0;
384
+ },
277
385
  };
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
386
  }
315
387
 
316
- function PanelIcon(props) {
317
- var size = (props && props.size) || 16;
318
- var active = props && props.active;
388
+ // -------------------------------------------------------------------
389
+ // Icons
390
+ // -------------------------------------------------------------------
391
+
392
+ function svg(props, paths) {
319
393
  return h(
320
394
  'svg',
321
395
  {
322
- width: size,
323
- height: size,
396
+ width: props.size || 16,
397
+ height: props.size || 16,
324
398
  viewBox: '0 0 24 24',
325
399
  fill: 'none',
326
400
  stroke: 'currentColor',
327
- strokeWidth: 1.8,
401
+ strokeWidth: props.strokeWidth || 1.9,
328
402
  strokeLinecap: 'round',
329
403
  strokeLinejoin: 'round',
330
404
  'aria-hidden': true,
331
405
  focusable: false,
332
- style: { opacity: active ? 1 : 0.86 },
333
406
  },
407
+ paths,
408
+ );
409
+ }
410
+
411
+ function icon(name, size) {
412
+ switch (name) {
413
+ case 'play':
414
+ return svg({ size: size }, [h('path', { key: 'a', d: 'M8 5.5v13l11-6.5-11-6.5Z', fill: 'currentColor', stroke: 'none' })]);
415
+ case 'stop':
416
+ return svg({ size: size }, [h('rect', { key: 'a', x: 6, y: 6, width: 12, height: 12, rx: 2.5, fill: 'currentColor', stroke: 'none' })]);
417
+ case 'back':
418
+ return svg({ size: size }, [h('path', { key: 'a', d: 'M14.5 6.5 8 12l6.5 5.5' })]);
419
+ case 'forward':
420
+ return svg({ size: size }, [h('path', { key: 'a', d: 'M9.5 6.5 16 12l-6.5 5.5' })]);
421
+ case 'reload':
422
+ return svg({ size: size }, [
423
+ h('path', { key: 'a', d: 'M19 12a7 7 0 1 1-2.1-5' }),
424
+ h('path', { key: 'b', d: 'M19 4.5V9h-4.5' }),
425
+ ]);
426
+ case 'chevron':
427
+ return svg({ size: size || 12 }, [h('path', { key: 'a', d: 'M9.5 6l6 6-6 6' })]);
428
+ case 'globe':
429
+ return svg({ size: size || 24, strokeWidth: 1.6 }, [
430
+ h('circle', { key: 'a', cx: 12, cy: 12, r: 8.5 }),
431
+ h('path', { key: 'b', d: 'M3.5 12h17' }),
432
+ 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' }),
433
+ ]);
434
+ default:
435
+ return svg({ size: size }, []);
436
+ }
437
+ }
438
+
439
+ function BrowserGlyph(props) {
440
+ return svg({ size: (props && props.size) || 18, strokeWidth: 1.8 }, [
334
441
  h('rect', { key: 'a', x: 3, y: 4.5, width: 18, height: 15, rx: 2.6 }),
335
442
  h('path', { key: 'b', d: 'M3 8.5h18' }),
336
443
  h('circle', { key: 'c', cx: 12, cy: 13.6, r: 2.2, fill: 'currentColor', stroke: 'none' }),
444
+ ]);
445
+ }
446
+
447
+ // -------------------------------------------------------------------
448
+ // Guide cards
449
+ // -------------------------------------------------------------------
450
+
451
+ /** The right-Sidebar controller, kept in step by `apply` for guide cards. */
452
+ var sidebarRightOpen = null;
453
+
454
+ function openTabFrom(info, kind) {
455
+ if (info && info.tab && info.tab.actions && typeof info.tab.actions.openTab === 'function') {
456
+ try {
457
+ info.tab.actions.openTab(kind, { replaceTab: true });
458
+ return;
459
+ } catch (error) {
460
+ console.warn('[dsh-visual-browser] tab.actions.openTab failed:', error && error.message ? error.message : error);
461
+ }
462
+ }
463
+ if (typeof sidebarRightOpen === 'function') {
464
+ try {
465
+ sidebarRightOpen(kind, { replaceTab: true });
466
+ return;
467
+ } catch (error) {
468
+ console.warn('[dsh-visual-browser] sidebarRight.openTab failed:', error && error.message ? error.message : error);
469
+ return;
470
+ }
471
+ }
472
+ console.warn('[dsh-visual-browser] no way to open tab', kind);
473
+ }
474
+
475
+ /** The card that carries our own entry when the built-in Browser is absent. */
476
+ function VisualBrowserGuideCard(props) {
477
+ var t = props.t || translateCurrent;
478
+ var info = typeof props.useTabInfo === 'function' ? props.useTabInfo() : null;
479
+ return h(
480
+ 'div',
481
+ { className: 'dvb-guide', 'data-dvb-guide': 'own' },
482
+ h(
483
+ 'button',
484
+ {
485
+ type: 'button',
486
+ className: 'dvb-guide-card',
487
+ 'data-sidebar-right-guide-entry': props.kind || KIND,
488
+ onClick: function () {
489
+ openTabFrom(info, props.kind || KIND);
490
+ },
491
+ },
492
+ h('span', { className: 'dvb-guide-glyph' }, h(BrowserGlyph, { size: 24 })),
493
+ h(
494
+ 'span',
495
+ { className: 'dvb-guide-text' },
496
+ h('span', { className: 'dvb-guide-title' }, props.title || t('guide.title')),
497
+ h('span', { className: 'dvb-guide-desc' }, props.description || t('guide.description')),
498
+ ),
499
+ icon('chevron', 14),
500
+ ),
337
501
  );
338
502
  }
339
503
 
340
- // ---------------------------------------------------------------------
341
- // Panel
342
- // ---------------------------------------------------------------------
504
+ /**
505
+ * The built-in Browser card, with one extra row under it: this is what
506
+ * puts a door to the Visual Browser directly beneath the system browser.
507
+ */
508
+ function BrowserExtraGuideCard(props) {
509
+ var t = props.t || translateCurrent;
510
+ var info = typeof props.useTabInfo === 'function' ? props.useTabInfo() : null;
511
+ return h(
512
+ 'div',
513
+ { className: 'dvb-guide', 'data-dvb-guide': 'browser' },
514
+ h(
515
+ 'button',
516
+ {
517
+ type: 'button',
518
+ className: 'dvb-guide-card',
519
+ 'data-sidebar-right-guide-entry': props.kind || BROWSER_KIND,
520
+ onClick: function () {
521
+ openTabFrom(info, props.kind || BROWSER_KIND);
522
+ },
523
+ },
524
+ h('span', { className: 'dvb-guide-glyph' }, icon('globe', 24)),
525
+ h(
526
+ 'span',
527
+ { className: 'dvb-guide-text' },
528
+ h('span', { className: 'dvb-guide-title' }, props.title || t('panel.title')),
529
+ props.description ? h('span', { className: 'dvb-guide-desc' }, props.description) : null,
530
+ ),
531
+ icon('chevron', 14),
532
+ ),
533
+ h('div', { className: 'dvb-guide-sep', role: 'none' }),
534
+ h(
535
+ 'button',
536
+ {
537
+ type: 'button',
538
+ className: 'dvb-guide-extra',
539
+ title: t('guide.open'),
540
+ onClick: function () {
541
+ openTabFrom(info, KIND);
542
+ },
543
+ },
544
+ h('span', { className: 'dvb-guide-extra-glyph' }, h(BrowserGlyph, { size: 16 })),
545
+ h(
546
+ 'span',
547
+ { className: 'dvb-guide-text' },
548
+ h('span', { className: 'dvb-guide-title' }, t('guide.title')),
549
+ h('span', { className: 'dvb-guide-desc' }, t('guide.description')),
550
+ ),
551
+ icon('chevron', 14),
552
+ ),
553
+ );
554
+ }
555
+
556
+ // -------------------------------------------------------------------
557
+ // Tab body
558
+ // -------------------------------------------------------------------
559
+
560
+ /**
561
+ * Height of the resizable action log. The panel keeps the choice in
562
+ * localStorage so a chosen split survives reopening the tab.
563
+ */
564
+ var LOG_HEIGHT_KEY = 'dsh.visual-browser.log-height.v1';
565
+ var LOG_MIN_HEIGHT = 96;
566
+ var LOG_STAGE_RESERVE = 220;
567
+
568
+ function readStoredLogHeight() {
569
+ try {
570
+ var raw = window.localStorage.getItem(LOG_HEIGHT_KEY);
571
+ var value = Number(raw);
572
+ if (!Number.isFinite(value) || value < LOG_MIN_HEIGHT) return null;
573
+ return Math.round(value);
574
+ } catch {
575
+ return null;
576
+ }
577
+ }
578
+
579
+ function storeLogHeight(value) {
580
+ try {
581
+ if (value === null) window.localStorage.removeItem(LOG_HEIGHT_KEY);
582
+ else window.localStorage.setItem(LOG_HEIGHT_KEY, String(Math.round(value)));
583
+ } catch {
584
+ /* storage can be unavailable; the choice just does not persist */
585
+ }
586
+ }
343
587
 
344
588
  var EMPTY_STATE = {
345
589
  running: false,
@@ -347,30 +591,70 @@
347
591
  url: '',
348
592
  title: '',
349
593
  viewport: { width: 1280, height: 800 },
350
- frameSeq: 0,
594
+ startedAt: 0,
351
595
  actionSeq: 0,
352
- frameSeqs: [],
353
596
  actions: [],
354
597
  allowUserControl: true,
355
598
  lastError: '',
356
599
  };
357
600
 
358
- function BrowserPage(props) {
601
+ var MODIFIER_MASK = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
602
+
603
+ function modifierMask(event) {
604
+ var mask = 0;
605
+ if (event.altKey) mask |= MODIFIER_MASK.Alt;
606
+ if (event.ctrlKey) mask |= MODIFIER_MASK.Control;
607
+ if (event.metaKey) mask |= MODIFIER_MASK.Meta;
608
+ if (event.shiftKey) mask |= MODIFIER_MASK.Shift;
609
+ return mask;
610
+ }
611
+
612
+ function typedText(event) {
613
+ if (event.ctrlKey || event.metaKey || event.altKey) return '';
614
+ if (typeof event.key !== 'string' || event.key.length !== 1) return '';
615
+ return event.key;
616
+ }
617
+
618
+ function BrowserTab(props) {
359
619
  var ctx = props.ctx;
360
- var t = translateCurrent;
620
+ var t = props.t || translateCurrent;
361
621
  var [tick, setTick] = useState(0);
362
622
  var [state, setState] = useState(EMPTY_STATE);
363
623
  var [reachable, setReachable] = useState(true);
364
624
  var [busy, setBusy] = useState('');
365
625
  var [error, setError] = useState('');
366
- var [live, setLive] = useState(false);
367
- var [liveTick, setLiveTick] = useState(0);
368
- var [previewSeq, setPreviewSeq] = useState(0);
369
626
  var [draft, setDraft] = useState('');
370
627
  var [dirty, setDirty] = useState(false);
628
+ var [logOpen, setLogOpen] = useState(true);
629
+ var [logHeight, setLogHeight] = useState(readStoredLogHeight);
630
+ var [resizing, setResizing] = useState(false);
631
+ var [live, setLive] = useState(false);
632
+ var [streamRetry, setStreamRetry] = useState(0);
633
+ var [retrying, setRetrying] = useState(false);
634
+ var [focused, setFocused] = useState(false);
635
+
636
+ var rootRef = useRef(null);
637
+ var logRef = useRef(null);
638
+ var logBodyRef = useRef(null);
639
+ var splitRef = useRef(null);
371
640
  var stageRef = useRef(null);
372
- var wheelAcc = useRef({ deltaY: 0, timer: 0 });
641
+ var canvasRef = useRef(null);
642
+ var liveRef = useRef(false);
643
+ var cursorRef = useRef(null);
644
+ var rippleRef = useRef(null);
645
+ var pointerRef = useRef({ down: false, id: 0, x: 0, y: 0, moved: 0, clicks: 1, lastUpAt: 0, lastUpX: 0, lastUpY: 0, button: 0 });
646
+ var stateJsonRef = useRef('');
647
+ var sender = useMemo(function () {
648
+ return createSender();
649
+ }, []);
373
650
 
651
+ useEffect(function () {
652
+ return function () {
653
+ sender.dispose();
654
+ };
655
+ }, [sender]);
656
+
657
+ // Re-render on language change only.
374
658
  useEffect(function () {
375
659
  var locale = ctx.get('locale');
376
660
  if (!locale || typeof locale.subscribe !== 'function') return undefined;
@@ -381,6 +665,7 @@
381
665
  });
382
666
  }, [ctx]);
383
667
 
668
+ // State poll: identity-compare the JSON so an idle panel never re-renders.
384
669
  useEffect(
385
670
  function () {
386
671
  var cancelled = false;
@@ -390,12 +675,18 @@
390
675
  var data = await requestJson('/state');
391
676
  if (cancelled) return;
392
677
  setReachable(true);
393
- if (data.state) setState(data.state);
678
+ if (data.state) {
679
+ var next = JSON.stringify(data.state);
680
+ if (next !== stateJsonRef.current) {
681
+ stateJsonRef.current = next;
682
+ setState(data.state);
683
+ }
684
+ }
394
685
  } catch {
395
686
  if (!cancelled) setReachable(false);
396
687
  }
397
688
  if (!cancelled) {
398
- timer = window.setTimeout(poll, document.visibilityState === 'hidden' ? 5000 : 1500);
689
+ timer = window.setTimeout(poll, document.visibilityState === 'hidden' ? 4000 : 900);
399
690
  }
400
691
  };
401
692
  poll();
@@ -414,104 +705,556 @@
414
705
  [state.url, dirty],
415
706
  );
416
707
 
708
+ // A new browser incarnation means a new stream.
709
+ useEffect(
710
+ function () {
711
+ liveRef.current = false;
712
+ setLive(false);
713
+ setRetrying(false);
714
+ },
715
+ [state.startedAt, state.running],
716
+ );
717
+
718
+ /**
719
+ * The live picture: one long binary response, each frame a length-
720
+ * prefixed JPEG decoded off the main thread and painted on a canvas.
721
+ * Decoding keeps at most one frame queued, so a burst never builds a
722
+ * backlog of stale pictures.
723
+ */
417
724
  useEffect(
418
725
  function () {
419
- if (!live || !state.running) return undefined;
420
- var id = window.setInterval(function () {
421
- setLiveTick(function (value) {
422
- return value + 1;
726
+ if (!state.running) return undefined;
727
+ var cancelled = false;
728
+ var controller = new AbortController();
729
+ var reader = null;
730
+ var buffer = new Uint8Array(0);
731
+ var pendingFrame = null;
732
+ var decoding = false;
733
+
734
+ var draw = async function (jpeg) {
735
+ var bitmap = await createImageBitmap(new Blob([jpeg], { type: 'image/jpeg' }));
736
+ var canvas = canvasRef.current;
737
+ if (canvas !== null) {
738
+ if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
739
+ canvas.width = bitmap.width;
740
+ canvas.height = bitmap.height;
741
+ }
742
+ var context = canvas.getContext('2d');
743
+ if (context) context.drawImage(bitmap, 0, 0);
744
+ if (!liveRef.current) {
745
+ liveRef.current = true;
746
+ setLive(true);
747
+ setRetrying(false);
748
+ }
749
+ }
750
+ bitmap.close();
751
+ };
752
+
753
+ var drain = async function () {
754
+ if (decoding) return;
755
+ decoding = true;
756
+ try {
757
+ while (!cancelled && pendingFrame !== null) {
758
+ var jpeg = pendingFrame;
759
+ pendingFrame = null;
760
+ try {
761
+ await draw(jpeg);
762
+ } catch {
763
+ /* a frame that will not decode is skipped */
764
+ }
765
+ }
766
+ } finally {
767
+ decoding = false;
768
+ }
769
+ };
770
+
771
+ var pump = async function () {
772
+ var response = await fetch(API + '/stream?r=' + streamRetry, {
773
+ signal: controller.signal,
774
+ cache: 'no-store',
423
775
  });
424
- }, 1100);
776
+ if (!response.ok || !response.body) throw new Error('stream ' + response.status);
777
+ reader = response.body.getReader();
778
+ for (;;) {
779
+ var step = await reader.read();
780
+ if (step.done) break;
781
+ var chunk = step.value;
782
+ var joined = new Uint8Array(buffer.length + chunk.length);
783
+ joined.set(buffer, 0);
784
+ joined.set(chunk, buffer.length);
785
+ buffer = joined;
786
+ while (buffer.length >= 8) {
787
+ var view = new DataView(buffer.buffer, buffer.byteOffset, 8);
788
+ var length = view.getUint32(0, true);
789
+ if (length === 0 || length > 8 * 1024 * 1024) {
790
+ buffer = new Uint8Array(0);
791
+ break;
792
+ }
793
+ if (buffer.length < 8 + length) break;
794
+ pendingFrame = buffer.slice(8, 8 + length);
795
+ buffer = buffer.slice(8 + length);
796
+ void drain();
797
+ }
798
+ }
799
+ throw new Error('stream ended');
800
+ };
801
+
802
+ pump().catch(function () {
803
+ if (cancelled) return;
804
+ liveRef.current = false;
805
+ setLive(false);
806
+ setRetrying(true);
807
+ window.setTimeout(function () {
808
+ if (!cancelled) {
809
+ setStreamRetry(function (value) {
810
+ return value + 1;
811
+ });
812
+ }
813
+ }, Math.min(4000, 600 + streamRetry * 400));
814
+ });
815
+
425
816
  return function () {
426
- window.clearInterval(id);
817
+ cancelled = true;
818
+ controller.abort();
819
+ try {
820
+ if (reader) void reader.cancel();
821
+ } catch {
822
+ /* already closed */
823
+ }
427
824
  };
428
825
  },
429
- [live, state.running],
826
+ [state.running, state.startedAt, streamRetry],
430
827
  );
431
828
 
432
- var run = useCallback(async function (label, path, body) {
433
- setBusy(label);
434
- setError('');
829
+ var refreshState = useCallback(async function () {
435
830
  try {
436
- var data = await requestJson(path, { method: 'POST', body: body || {} });
437
- if (data && data.state) {
831
+ var data = await requestJson('/state');
832
+ setReachable(true);
833
+ if (data.state) {
834
+ stateJsonRef.current = JSON.stringify(data.state);
438
835
  setState(data.state);
439
- setReachable(true);
440
836
  }
441
- } catch (failure) {
442
- setError(failure && failure.message ? failure.message : String(failure));
443
- } finally {
444
- setBusy('');
837
+ } catch {
838
+ setReachable(false);
445
839
  }
446
840
  }, []);
447
841
 
448
- var sendWheel = useCallback(
449
- function (deltaY) {
450
- if (!state.running || !state.allowUserControl) return;
451
- void run('wheel', '/wheel', { deltaY: Math.round(deltaY) });
842
+ var run = useCallback(
843
+ async function (label, path, body) {
844
+ setBusy(label);
845
+ setError('');
846
+ try {
847
+ var data = await requestJson(path, { method: 'POST', body: body || {} });
848
+ if (data && data.state) {
849
+ stateJsonRef.current = JSON.stringify(data.state);
850
+ setState(data.state);
851
+ setReachable(true);
852
+ }
853
+ } catch (failure) {
854
+ setError(failure && failure.message ? failure.message : String(failure));
855
+ } finally {
856
+ setBusy('');
857
+ }
452
858
  },
453
- [run, state.running, state.allowUserControl],
859
+ [],
454
860
  );
455
861
 
862
+ // Geometry of the picture in CSS pixels, read on demand (never cached).
863
+ var frameRect = function () {
864
+ var canvas = canvasRef.current;
865
+ if (!canvas) return null;
866
+ var rect = canvas.getBoundingClientRect();
867
+ if (!rect || rect.width < 2 || rect.height < 2) return null;
868
+ return rect;
869
+ };
870
+
871
+ var toNormalized = function (event) {
872
+ var rect = frameRect();
873
+ if (!rect) return null;
874
+ var x = (event.clientX - rect.left) / rect.width;
875
+ var y = (event.clientY - rect.top) / rect.height;
876
+ if (x < -0.02 || x > 1.02 || y < -0.02 || y > 1.02) return null;
877
+ return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };
878
+ };
879
+
880
+ var placeCursor = function (event) {
881
+ var stage = stageRef.current;
882
+ var ring = cursorRef.current;
883
+ if (!stage || !ring) return;
884
+ var stageRect = stage.getBoundingClientRect();
885
+ var x = event.clientX - stageRect.left;
886
+ var y = event.clientY - stageRect.top;
887
+ ring.style.transform = 'translate3d(' + x + 'px,' + y + 'px,0)';
888
+ ring.setAttribute('data-visible', 'true');
889
+ };
890
+
891
+ var hideCursor = function () {
892
+ var ring = cursorRef.current;
893
+ if (ring) ring.setAttribute('data-visible', 'false');
894
+ };
895
+
896
+ var ripple = function (event) {
897
+ var stage = stageRef.current;
898
+ var mark = rippleRef.current;
899
+ if (!stage || !mark || typeof mark.animate !== 'function') return;
900
+ var stageRect = stage.getBoundingClientRect();
901
+ var x = event.clientX - stageRect.left;
902
+ var y = event.clientY - stageRect.top;
903
+ try {
904
+ mark.animate(
905
+ [
906
+ { transform: 'translate3d(' + x + 'px,' + y + 'px,0) scale(.35)', opacity: 0.5 },
907
+ { transform: 'translate3d(' + x + 'px,' + y + 'px,0) scale(2.1)', opacity: 0 },
908
+ ],
909
+ { duration: 340, easing: 'ease-out' },
910
+ );
911
+ } catch {
912
+ /* older engines simply skip the ripple */
913
+ }
914
+ };
915
+
916
+ var clickCountFor = function (point) {
917
+ var pointer = pointerRef.current;
918
+ var now = Date.now();
919
+ var rect = frameRect();
920
+ var width = rect ? rect.width : 1;
921
+ var height = rect ? rect.height : 1;
922
+ var near =
923
+ Math.abs(point.x - pointer.lastUpX) * width < 6 && Math.abs(point.y - pointer.lastUpY) * height < 6;
924
+ var count = now - pointer.lastUpAt < 420 && near ? 2 : 1;
925
+ pointer.lastUpAt = now;
926
+ pointer.lastUpX = point.x;
927
+ pointer.lastUpY = point.y;
928
+ return count;
929
+ };
930
+
931
+ var sendFlush = function () {
932
+ // Gesture boundaries are worth an immediate flush; a held-down drag is not.
933
+ sender.flushNow();
934
+ window.setTimeout(function () {
935
+ void refreshState();
936
+ }, 220);
937
+ };
938
+
939
+ var onPointerDown = function (event) {
940
+ if (!state.running || !state.allowUserControl) return;
941
+ if (event.button !== 0 && event.button !== 2 && event.button !== 1) return;
942
+ var point = toNormalized(event);
943
+ if (!point) return;
944
+ event.preventDefault();
945
+ var stage = stageRef.current;
946
+ if (stage && typeof stage.focus === 'function') {
947
+ try {
948
+ stage.focus({ preventScroll: true });
949
+ } catch {
950
+ /* focus on an old engine */
951
+ }
952
+ }
953
+ var pointer = pointerRef.current;
954
+ pointer.down = true;
955
+ pointer.id = event.pointerId;
956
+ pointer.x = point.x;
957
+ pointer.y = point.y;
958
+ pointer.moved = 0;
959
+ pointer.button = event.button;
960
+ pointer.clicks = pointer.button === 0 ? clickCountFor(point) : 1;
961
+ try {
962
+ if (event.currentTarget.setPointerCapture) event.currentTarget.setPointerCapture(event.pointerId);
963
+ } catch {
964
+ /* capture is a nicety, not a requirement */
965
+ }
966
+ placeCursor(event);
967
+ ripple(event);
968
+ var masked = modifierMask(event);
969
+ sender.send({ t: 'pointer', type: 'move', x: point.x, y: point.y, buttons: event.buttons || 0, modifiers: masked });
970
+ sender.send({
971
+ t: 'pointer',
972
+ type: 'down',
973
+ x: point.x,
974
+ y: point.y,
975
+ button: event.button,
976
+ buttons: event.buttons || 1,
977
+ clicks: pointer.clicks,
978
+ modifiers: masked,
979
+ });
980
+ };
981
+
982
+ var onPointerMove = function (event) {
983
+ if (!state.running || !state.allowUserControl) return;
984
+ placeCursor(event);
985
+ var point = toNormalized(event);
986
+ if (!point) return;
987
+ var pointer = pointerRef.current;
988
+ if (!pointer.down) {
989
+ // Hover matters for menus and tooltips; folding happens in the sender.
990
+ sender.send({ t: 'pointer', type: 'move', x: point.x, y: point.y, buttons: 0, modifiers: modifierMask(event) });
991
+ return;
992
+ }
993
+ var rect = frameRect();
994
+ if (rect) {
995
+ var dx = (point.x - pointer.x) * rect.width;
996
+ var dy = (point.y - pointer.y) * rect.height;
997
+ pointer.moved = Math.max(pointer.moved, Math.sqrt(dx * dx + dy * dy));
998
+ }
999
+ sender.send({ t: 'pointer', type: 'move', x: point.x, y: point.y, buttons: event.buttons || 0, modifiers: modifierMask(event) });
1000
+ };
1001
+
1002
+ var onPointerUp = function (event) {
1003
+ var pointer = pointerRef.current;
1004
+ if (!pointer.down) return;
1005
+ if (!state.running || !state.allowUserControl) {
1006
+ pointer.down = false;
1007
+ return;
1008
+ }
1009
+ pointer.down = false;
1010
+ try {
1011
+ if (event.currentTarget.releasePointerCapture) event.currentTarget.releasePointerCapture(event.pointerId);
1012
+ } catch {
1013
+ /* already released */
1014
+ }
1015
+ var point = toNormalized(event) || { x: pointer.x, y: pointer.y };
1016
+ var masked = modifierMask(event);
1017
+ var viewportWidth = state.viewport && state.viewport.width ? state.viewport.width : 1280;
1018
+ var viewportHeight = state.viewport && state.viewport.height ? state.viewport.height : 800;
1019
+ var travel = Math.sqrt(
1020
+ Math.pow((point.x - pointer.x) * viewportWidth, 2) + Math.pow((point.y - pointer.y) * viewportHeight, 2),
1021
+ );
1022
+ var drag = Math.max(pointer.moved, travel) > 4;
1023
+ sender.send({ t: 'pointer', type: 'move', x: point.x, y: point.y, buttons: 0, modifiers: masked });
1024
+ sender.send({
1025
+ t: 'pointer',
1026
+ type: 'up',
1027
+ x: point.x,
1028
+ y: point.y,
1029
+ button: pointer.button,
1030
+ buttons: 0,
1031
+ clicks: pointer.clicks || 1,
1032
+ drag: drag,
1033
+ modifiers: masked,
1034
+ });
1035
+ sendFlush();
1036
+ };
1037
+
1038
+ // Native wheel listener: React's is passive in places, and the picture
1039
+ // must never scroll the sidebar around it.
456
1040
  useEffect(
457
1041
  function () {
458
- var stage = stageRef.current;
459
- if (!stage) return undefined;
1042
+ var canvas = canvasRef.current;
1043
+ if (!canvas) return undefined;
460
1044
  var onWheel = function (event) {
461
1045
  if (!state.running || !state.allowUserControl) return;
462
1046
  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);
1047
+ var rect = frameRect();
1048
+ if (!rect) return;
1049
+ var x = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
1050
+ var y = Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height));
1051
+ var scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? rect.height : 1;
1052
+ sender.send({
1053
+ t: 'wheel',
1054
+ x: x,
1055
+ y: y,
1056
+ dx: Math.round(event.deltaX * scale),
1057
+ dy: Math.round(event.deltaY * scale),
1058
+ modifiers: modifierMask(event),
1059
+ });
472
1060
  };
473
- stage.addEventListener('wheel', onWheel, { passive: false });
1061
+ canvas.addEventListener('wheel', onWheel, { passive: false });
474
1062
  return function () {
475
- stage.removeEventListener('wheel', onWheel);
476
- var acc = wheelAcc.current;
477
- if (acc.timer) window.clearTimeout(acc.timer);
1063
+ canvas.removeEventListener('wheel', onWheel);
478
1064
  };
479
1065
  },
480
- [sendWheel, state.running, state.allowUserControl],
1066
+ [sender, state.running, state.allowUserControl, live],
481
1067
  );
482
1068
 
483
- var onFrameClick = function (event) {
1069
+ var onKeyDown = function (event) {
484
1070
  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 });
1071
+ var text = typedText(event);
1072
+ if (text === '' && (event.ctrlKey || event.metaKey) && event.key === 'v') {
1073
+ return; // paste is handled by onPaste with real clipboard text
1074
+ }
1075
+ event.preventDefault();
1076
+ sender.send({
1077
+ t: 'key',
1078
+ type: 'down',
1079
+ key: event.key,
1080
+ code: event.code,
1081
+ keyCode: event.keyCode,
1082
+ text: text,
1083
+ repeat: event.repeat === true,
1084
+ modifiers: modifierMask(event),
1085
+ });
1086
+ sender.flushNow();
1087
+ };
1088
+
1089
+ var onKeyUp = function (event) {
1090
+ if (!state.running || !state.allowUserControl) return;
1091
+ event.preventDefault();
1092
+ sender.send({
1093
+ t: 'key',
1094
+ type: 'up',
1095
+ key: event.key,
1096
+ code: event.code,
1097
+ keyCode: event.keyCode,
1098
+ modifiers: modifierMask(event),
1099
+ });
1100
+ sender.flushNow();
492
1101
  };
493
1102
 
494
- var frameSrc = useMemo(
1103
+ var onPaste = function (event) {
1104
+ if (!state.running || !state.allowUserControl) return;
1105
+ var data = event.clipboardData;
1106
+ var text = data ? data.getData('text/plain') : '';
1107
+ if (!text) return;
1108
+ event.preventDefault();
1109
+ sender.send({ t: 'text', text: text });
1110
+ sender.flushNow();
1111
+ };
1112
+
1113
+ var openOrStart = function () {
1114
+ void run('open', '/start', { url: draft.trim() });
1115
+ setDirty(false);
1116
+ };
1117
+
1118
+ var retryStream = function () {
1119
+ setRetrying(true);
1120
+ setStreamRetry(function (value) {
1121
+ return value + 1;
1122
+ });
1123
+ };
1124
+
1125
+ // Keeps a stored split inside the panel: the picture always keeps its
1126
+ // reserve, however the pane is resized later.
1127
+ useEffect(
495
1128
  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);
1129
+ if (logHeight === null) return undefined;
1130
+ var root = rootRef.current;
1131
+ if (!root) return undefined;
1132
+ var clampToPanel = function () {
1133
+ var limit = Math.max(LOG_MIN_HEIGHT, root.clientHeight - LOG_STAGE_RESERVE);
1134
+ if (logHeight > limit) {
1135
+ setLogHeight(limit);
1136
+ storeLogHeight(limit);
1137
+ }
1138
+ };
1139
+ clampToPanel();
1140
+ if (typeof ResizeObserver !== 'function') return undefined;
1141
+ var observer = new ResizeObserver(clampToPanel);
1142
+ observer.observe(root);
1143
+ return function () {
1144
+ observer.disconnect();
1145
+ };
499
1146
  },
500
- [previewSeq, live, liveTick, state.frameSeq, state.running],
1147
+ [logHeight, logOpen],
501
1148
  );
502
1149
 
503
- var hasFrame = previewSeq > 0 || (state.running && state.frameSeq > 0);
1150
+ var applyLogHeight = function (value) {
1151
+ var log = logRef.current;
1152
+ if (!log) return;
1153
+ if (value === null) {
1154
+ log.removeAttribute('data-sized');
1155
+ log.style.removeProperty('--dvb-log-h');
1156
+ } else {
1157
+ log.setAttribute('data-sized', 'true');
1158
+ log.style.setProperty('--dvb-log-h', Math.round(value) + 'px');
1159
+ }
1160
+ };
1161
+
1162
+ var commitLogHeight = function (value) {
1163
+ setLogHeight(value);
1164
+ storeLogHeight(value);
1165
+ applyLogHeight(value);
1166
+ };
1167
+
1168
+ // The CSS `height` is a content-box height; measuring the same box keeps
1169
+ // a drag from jumping by the body's padding on the first pixel.
1170
+ var currentLogHeight = function () {
1171
+ var body = logBodyRef.current;
1172
+ if (!body) return LOG_MIN_HEIGHT;
1173
+ var computed = window.getComputedStyle(body).height;
1174
+ var value = Number(String(computed).replace('px', ''));
1175
+ return Number.isFinite(value) && value > 0 ? value : body.getBoundingClientRect().height;
1176
+ };
1177
+
1178
+ var logHeightLimit = function () {
1179
+ var root = rootRef.current;
1180
+ if (!root) return 420;
1181
+ return Math.max(LOG_MIN_HEIGHT, root.clientHeight - LOG_STAGE_RESERVE);
1182
+ };
1183
+
1184
+ // Drag the divider between the picture and the log.
1185
+ var onSplitDown = function (event) {
1186
+ if (event.button !== undefined && event.button !== 0) return;
1187
+ event.preventDefault();
1188
+ event.stopPropagation();
1189
+ var startHeight = currentLogHeight();
1190
+ var startY = event.clientY;
1191
+ var pointerId = event.pointerId;
1192
+ var handle = event.currentTarget;
1193
+ var active = true;
1194
+ setResizing(true);
1195
+ try {
1196
+ if (handle.setPointerCapture) handle.setPointerCapture(pointerId);
1197
+ } catch {
1198
+ /* capture is a nicety */
1199
+ }
1200
+
1201
+ var move = function (moveEvent) {
1202
+ if (!active) return;
1203
+ var limit = logHeightLimit();
1204
+ var next = Math.min(limit, Math.max(LOG_MIN_HEIGHT, Math.round(startHeight + (startY - moveEvent.clientY))));
1205
+ applyLogHeight(next);
1206
+ };
1207
+ var finish = function () {
1208
+ if (!active) return;
1209
+ active = false;
1210
+ handle.removeEventListener('pointermove', move);
1211
+ handle.removeEventListener('pointerup', finish);
1212
+ handle.removeEventListener('pointercancel', finish);
1213
+ try {
1214
+ if (handle.releasePointerCapture) handle.releasePointerCapture(pointerId);
1215
+ } catch {
1216
+ /* already released */
1217
+ }
1218
+ setResizing(false);
1219
+ var log = logRef.current;
1220
+ var value = log ? Number(String(log.style.getPropertyValue('--dvb-log-h')).replace('px', '')) : NaN;
1221
+ if (Number.isFinite(value) && value > 0) commitLogHeight(Math.round(value));
1222
+ else commitLogHeight(null);
1223
+ };
1224
+
1225
+ handle.addEventListener('pointermove', move);
1226
+ handle.addEventListener('pointerup', finish);
1227
+ handle.addEventListener('pointercancel', finish);
1228
+ };
1229
+
1230
+ var onSplitKeyDown = function (event) {
1231
+ var step = event.shiftKey ? 64 : 24;
1232
+ var current = currentLogHeight();
1233
+ if (event.key === 'ArrowUp') {
1234
+ event.preventDefault();
1235
+ commitLogHeight(Math.min(logHeightLimit(), Math.round(current + step)));
1236
+ } else if (event.key === 'ArrowDown') {
1237
+ event.preventDefault();
1238
+ commitLogHeight(Math.max(LOG_MIN_HEIGHT, Math.round(current - step)));
1239
+ } else if (event.key === 'Home') {
1240
+ event.preventDefault();
1241
+ commitLogHeight(LOG_MIN_HEIGHT);
1242
+ } else if (event.key === 'End') {
1243
+ event.preventDefault();
1244
+ commitLogHeight(logHeightLimit());
1245
+ }
1246
+ };
1247
+
1248
+ var resetLogHeight = function () {
1249
+ commitLogHeight(null);
1250
+ };
1251
+
504
1252
  var kindLabel = function (kind) {
505
1253
  var key = 'kind.' + kind;
506
1254
  var text = t(key);
507
1255
  return text === key ? kind : text;
508
1256
  };
509
1257
 
510
- var openOrStart = function () {
511
- void run('open', '/start', { url: draft.trim() });
512
- setDirty(false);
513
- };
514
-
515
1258
  var statusText = !reachable
516
1259
  ? t('state.offline')
517
1260
  : state.starting
@@ -520,275 +1263,293 @@
520
1263
  ? t('state.running')
521
1264
  : t('state.stopped');
522
1265
 
1266
+ var actions = state.actions || [];
1267
+
523
1268
  return h(
524
1269
  'div',
525
- { className: 'dvb-page' },
1270
+ { className: 'dvb-root', ref: rootRef },
526
1271
  h(
527
1272
  'div',
528
- { className: 'dvb-head' },
1273
+ { className: 'dvb-bar' },
1274
+ h('span', {
1275
+ className: 'dvb-dot',
1276
+ 'data-on': String(state.running),
1277
+ 'data-error': String(!reachable),
1278
+ title: statusText + (state.headless ? ' · headless' : ''),
1279
+ }),
529
1280
  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
- ),
1281
+ 'span',
1282
+ { className: 'dvb-title', title: state.title || state.url || t('panel.title') },
1283
+ state.title || shortUrl(state.url) || t('panel.title'),
649
1284
  ),
1285
+ h('span', { className: 'dvb-spacer' }),
1286
+ busy ? h('span', { className: 'dvb-status' }, t('busy')) : null,
650
1287
  h(
651
1288
  '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);
1289
+ { className: 'dvb-tools' },
1290
+ h(
1291
+ 'button',
1292
+ {
1293
+ type: 'button',
1294
+ className: 'dvb-btn',
1295
+ title: t('action.back'),
1296
+ 'aria-label': t('action.back'),
1297
+ disabled: !state.running || Boolean(busy),
1298
+ onClick: function () {
1299
+ void run('back', '/nav', { action: 'back' });
1300
+ },
662
1301
  },
663
- onKeyDown: function (event) {
664
- if (event.key === 'Enter') {
665
- event.preventDefault();
666
- openOrStart();
667
- }
1302
+ icon('back', 16),
1303
+ ),
1304
+ h(
1305
+ 'button',
1306
+ {
1307
+ type: 'button',
1308
+ className: 'dvb-btn',
1309
+ title: t('action.forward'),
1310
+ 'aria-label': t('action.forward'),
1311
+ disabled: !state.running || Boolean(busy),
1312
+ onClick: function () {
1313
+ void run('forward', '/nav', { action: 'forward' });
1314
+ },
668
1315
  },
669
- }),
1316
+ icon('forward', 16),
1317
+ ),
670
1318
  h(
671
1319
  'button',
672
1320
  {
673
1321
  type: 'button',
674
1322
  className: 'dvb-btn',
675
- disabled: Boolean(busy) || draft.trim() === '',
676
- onClick: openOrStart,
1323
+ title: t('action.reload'),
1324
+ 'aria-label': t('action.reload'),
1325
+ disabled: !state.running || Boolean(busy),
1326
+ onClick: function () {
1327
+ void run('reload', '/nav', { action: 'reload' });
1328
+ },
677
1329
  },
678
- t('action.open'),
1330
+ icon('reload', 16),
679
1331
  ),
1332
+ state.running
1333
+ ? h(
1334
+ 'button',
1335
+ {
1336
+ type: 'button',
1337
+ className: 'dvb-btn',
1338
+ title: t('action.stop'),
1339
+ 'aria-label': t('action.stop'),
1340
+ disabled: Boolean(busy),
1341
+ onClick: function () {
1342
+ void run('stop', '/stop', {});
1343
+ },
1344
+ },
1345
+ icon('stop', 16),
1346
+ )
1347
+ : h(
1348
+ 'button',
1349
+ {
1350
+ type: 'button',
1351
+ className: 'dvb-btn',
1352
+ 'data-variant': 'primary',
1353
+ disabled: Boolean(busy),
1354
+ onClick: openOrStart,
1355
+ },
1356
+ icon('play', 14),
1357
+ t('action.start'),
1358
+ ),
1359
+ ),
1360
+ ),
1361
+ h(
1362
+ 'div',
1363
+ { className: 'dvb-addr' },
1364
+ h('input', {
1365
+ className: 'dvb-input',
1366
+ type: 'text',
1367
+ spellCheck: false,
1368
+ placeholder: t('url.placeholder'),
1369
+ value: draft,
1370
+ onChange: function (event) {
1371
+ setDirty(true);
1372
+ setDraft(event.target.value);
1373
+ },
1374
+ onKeyDown: function (event) {
1375
+ if (event.key === 'Enter') {
1376
+ event.preventDefault();
1377
+ openOrStart();
1378
+ }
1379
+ },
1380
+ }),
1381
+ h(
1382
+ 'button',
1383
+ {
1384
+ type: 'button',
1385
+ className: 'dvb-btn',
1386
+ disabled: Boolean(busy) || draft.trim() === '',
1387
+ onClick: openOrStart,
1388
+ },
1389
+ t('action.open'),
680
1390
  ),
681
1391
  ),
682
1392
  error ? h('div', { className: 'dvb-error' }, error) : null,
683
1393
  !reachable ? h('div', { className: 'dvb-error' }, t('error.offline')) : null,
684
1394
  h(
685
1395
  '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
- )
1396
+ {
1397
+ className: 'dvb-stage',
1398
+ ref: stageRef,
1399
+ tabIndex: 0,
1400
+ 'data-focus': String(focused),
1401
+ onFocus: function () {
1402
+ setFocused(true);
1403
+ },
1404
+ onBlur: function () {
1405
+ setFocused(false);
1406
+ },
1407
+ onKeyDown: onKeyDown,
1408
+ onKeyUp: onKeyUp,
1409
+ onPaste: onPaste,
1410
+ onContextMenu: function (event) {
1411
+ event.preventDefault();
1412
+ },
1413
+ },
1414
+ state.running
1415
+ ? h('canvas', {
1416
+ ref: canvasRef,
1417
+ className: 'dvb-live',
1418
+ width: state.viewport && state.viewport.width ? state.viewport.width : 1280,
1419
+ height: state.viewport && state.viewport.height ? state.viewport.height : 800,
1420
+ role: 'img',
1421
+ 'aria-label': state.title || state.url || t('panel.title'),
1422
+ 'data-busy': String(!live),
1423
+ onPointerDown: onPointerDown,
1424
+ onPointerMove: onPointerMove,
1425
+ onPointerUp: onPointerUp,
1426
+ onPointerCancel: onPointerUp,
1427
+ onPointerLeave: hideCursor,
1428
+ onContextMenu: function (event) {
1429
+ event.preventDefault();
1430
+ },
1431
+ onDragStart: function (event) {
1432
+ event.preventDefault();
1433
+ },
1434
+ })
705
1435
  : h(
706
1436
  'div',
707
1437
  { 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
- ),
1438
+ h(BrowserGlyph, { size: 44 }),
721
1439
  h('div', { className: 'dvb-empty-title' }, t('empty.title')),
722
1440
  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
1441
  ),
1442
+ state.running && !live
1443
+ ? h(
1444
+ 'button',
1445
+ {
1446
+ type: 'button',
1447
+ className: 'dvb-veil',
1448
+ onClick: retryStream,
1449
+ title: t('action.reconnect'),
1450
+ },
1451
+ t(retrying ? 'error.frame' : 'connecting'),
1452
+ )
1453
+ : null,
1454
+ h('span', { className: 'dvb-cursor', ref: cursorRef, 'data-visible': 'false' }),
1455
+ h('span', { className: 'dvb-ripple', ref: rippleRef }),
734
1456
  ),
1457
+ state.running && state.allowUserControl
1458
+ ? h(
1459
+ 'div',
1460
+ { className: 'dvb-hint' },
1461
+ h('span', null, t('hint.interactive')),
1462
+ state.headless ? h('span', null, t('hint.headless')) : null,
1463
+ )
1464
+ : null,
1465
+ logOpen
1466
+ ? h('button', {
1467
+ type: 'button',
1468
+ ref: splitRef,
1469
+ className: 'dvb-split',
1470
+ role: 'separator',
1471
+ 'aria-orientation': 'horizontal',
1472
+ 'aria-label': t('timeline.resize'),
1473
+ 'aria-valuemin': LOG_MIN_HEIGHT,
1474
+ 'aria-valuemax': logHeightLimit(),
1475
+ 'aria-valuenow': logHeight !== null ? logHeight : undefined,
1476
+ title: t('timeline.resize'),
1477
+ 'data-active': String(resizing),
1478
+ onPointerDown: onSplitDown,
1479
+ onKeyDown: onSplitKeyDown,
1480
+ onDoubleClick: resetLogHeight,
1481
+ })
1482
+ : null,
735
1483
  h(
736
1484
  'div',
737
- { className: 'dvb-timeline' },
1485
+ {
1486
+ className: 'dvb-log',
1487
+ ref: logRef,
1488
+ 'data-open': String(logOpen),
1489
+ 'data-sized': String(logHeight !== null),
1490
+ style: logHeight !== null ? { '--dvb-log-h': logHeight + 'px' } : undefined,
1491
+ },
738
1492
  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) : ''),
1493
+ 'button',
1494
+ {
1495
+ type: 'button',
1496
+ className: 'dvb-log-head',
1497
+ title: logOpen ? t('timeline.collapse') : t('timeline.expand'),
1498
+ 'aria-expanded': String(logOpen),
1499
+ onClick: function () {
1500
+ setLogOpen(function (value) {
1501
+ return !value;
1502
+ });
1503
+ },
1504
+ },
1505
+ h('span', { className: 'dvb-log-chevron' }, icon('chevron', 12)),
1506
+ h('span', { className: 'dvb-log-title' }, t('timeline.title')),
1507
+ h('span', { className: 'dvb-log-count' }, actions.length > 0 ? String(actions.length) : ''),
743
1508
  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,
1509
+ reachable ? null : h('span', { className: 'dvb-status' }, t('state.offline')),
757
1510
  ),
758
- state.actions.length === 0
759
- ? h('div', { className: 'dvb-empty-timeline' }, t('timeline.empty'))
760
- : h(
1511
+ logOpen
1512
+ ? h(
761
1513
  '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
- ),
1514
+ { className: 'dvb-log-body', ref: logBodyRef },
1515
+ actions.length === 0
1516
+ ? h('div', { className: 'dvb-log-empty' }, t('timeline.empty'))
1517
+ : actions.map(function (action) {
1518
+ var label = action.detail || action.target || kindLabel(action.kind);
1519
+ return h(
1520
+ 'div',
1521
+ { key: action.seq, className: 'dvb-row', title: label },
1522
+ h('span', { className: 'dvb-row-time' }, clock(action.ts)),
1523
+ h('span', { className: 'dvb-row-kind', 'data-kind': action.kind }, kindLabel(action.kind)),
1524
+ h('span', { className: 'dvb-row-text', 'data-error': String(action.ok === false) }, label),
1525
+ );
1526
+ }),
1527
+ )
1528
+ : null,
785
1529
  ),
786
1530
  );
787
1531
  }
788
1532
 
789
- // ---------------------------------------------------------------------
1533
+ // -------------------------------------------------------------------
1534
+ // Tab title
1535
+ // -------------------------------------------------------------------
1536
+
1537
+ function BrowserTabTitle(props) {
1538
+ var t = props.t || translateCurrent;
1539
+ var info = null;
1540
+ if (typeof props.useTabInfo === 'function') info = props.useTabInfo();
1541
+ var title = info && info.tab && info.tab.title ? info.tab.title : t('panel.title');
1542
+ return h(
1543
+ 'span',
1544
+ { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
1545
+ h(BrowserGlyph, { size: 15 }),
1546
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, title),
1547
+ );
1548
+ }
1549
+
1550
+ // -------------------------------------------------------------------
790
1551
  // Module
791
- // ---------------------------------------------------------------------
1552
+ // -------------------------------------------------------------------
792
1553
 
793
1554
  return {
794
1555
  inject: ['slots', 'locale'],
@@ -800,6 +1561,7 @@
800
1561
  console.warn('[dsh-visual-browser] slots service missing; panel disabled');
801
1562
  return;
802
1563
  }
1564
+
803
1565
  var register = function (slot, options, component, label) {
804
1566
  try {
805
1567
  ctx.effect(function () {
@@ -811,23 +1573,93 @@
811
1573
  console.warn('[dsh-visual-browser] could not register ' + slot + ':', error && error.message ? error.message : error);
812
1574
  }
813
1575
  };
814
- register(
815
- 'sidebar.panellist',
816
- {
817
- id: 'visual-browser',
818
- order: 40,
819
- label: function () {
820
- return translateCurrent('panel.title');
1576
+
1577
+ // A guide card may open our tab through `tab.actions`, but the
1578
+ // controller keeps working from any seat; hold it for the fallback.
1579
+ ctx.inject(['sidebarRight'], function (scope) {
1580
+ var service = scope.get('sidebarRight');
1581
+ if (!service || typeof service.openTab !== 'function') return;
1582
+ var open = service.openTab.bind(service);
1583
+ sidebarRightOpen = open;
1584
+ scope.effect(
1585
+ function () {
1586
+ return function () {
1587
+ if (sidebarRightOpen === open) sidebarRightOpen = null;
1588
+ };
1589
+ },
1590
+ 'dsh-visual-browser: sidebar controller handle',
1591
+ );
1592
+ });
1593
+
1594
+ // The tab type waits for the sidebar-right registry: sampling it once
1595
+ // in apply leaves the type unregistered on a cold page boot.
1596
+ ctx.inject(['sidebarRightTabs'], function (scope) {
1597
+ var tabs = scope.get('sidebarRightTabs');
1598
+ if (!tabs || typeof tabs.register !== 'function') {
1599
+ console.warn('[dsh-visual-browser] sidebarRightTabs service missing; tab type not registered');
1600
+ return;
1601
+ }
1602
+ scope.effect(
1603
+ function () {
1604
+ return tabs.register({
1605
+ id: MODULE_ID,
1606
+ kind: KIND,
1607
+ priority: 'extension',
1608
+ title: function () {
1609
+ return translateCurrent('panel.title');
1610
+ },
1611
+ guide: [
1612
+ {
1613
+ id: 'open',
1614
+ order: 40,
1615
+ title: function () {
1616
+ return translateCurrent('guide.title');
1617
+ },
1618
+ description: function () {
1619
+ return translateCurrent('guide.description');
1620
+ },
1621
+ },
1622
+ ],
1623
+ });
821
1624
  },
1625
+ 'dsh-visual-browser: tab type',
1626
+ );
1627
+ });
1628
+
1629
+ register(
1630
+ 'sidebar.right.pane.tab',
1631
+ { key: MODULE_ID, locale: NS },
1632
+ function (props) {
1633
+ return h(BrowserTab, { ctx: ctx, t: props.t });
822
1634
  },
1635
+ 'dsh-visual-browser: tab body',
1636
+ );
1637
+ register(
1638
+ 'sidebar.right.pane.tab.title',
1639
+ { key: MODULE_ID, locale: NS },
1640
+ BrowserTabTitle,
1641
+ 'dsh-visual-browser: tab title',
1642
+ );
1643
+ // Our own card only stands in when the built-in Browser has no card
1644
+ // for us to extend; normally the Browser card carries the extra row.
1645
+ register(
1646
+ 'sidebar.right.tab.guide.entry',
1647
+ { key: MODULE_ID, locale: NS },
823
1648
  function (props) {
824
- return h(PanelIcon, { size: props && props.size ? props.size : 16, active: Boolean(props && props.active) });
1649
+ var tabs = ctx.get('sidebarRightTabs');
1650
+ var browserPresent =
1651
+ tabs !== undefined && typeof tabs.get === 'function' && tabs.get(BROWSER_KIND) !== undefined;
1652
+ if (browserPresent) return null;
1653
+ return h(VisualBrowserGuideCard, props);
825
1654
  },
826
- 'dsh-visual-browser: sidebar entry',
1655
+ 'dsh-visual-browser: own guide card',
1656
+ );
1657
+ register(
1658
+ 'sidebar.right.tab.guide.entry',
1659
+ { key: BROWSER_TYPE_ID, locale: NS },
1660
+ BrowserExtraGuideCard,
1661
+ 'dsh-visual-browser: browser guide card',
827
1662
  );
828
- register('main', { key: 'visual-browser' }, function () {
829
- return h(BrowserPage, { ctx: ctx });
830
- }, 'dsh-visual-browser: main page');
831
1663
  },
832
1664
  };
833
1665
  },