ms-vite-plugin 1.4.4 → 1.4.6
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.
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>实时画面</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--primary-color: var(--vscode-button-background, #007acc);
|
|
10
|
+
--bg-color: var(--vscode-editor-background, #1e1e1e);
|
|
11
|
+
--text-color: var(--vscode-editor-foreground, #e0e0e0);
|
|
12
|
+
--danger-color: var(--vscode-errorForeground, #d73a49);
|
|
13
|
+
|
|
14
|
+
--panel-bg: rgba(30, 30, 30, 0.7);
|
|
15
|
+
--panel-border: rgba(255, 255, 255, 0.1);
|
|
16
|
+
--btn-bg: rgba(255, 255, 255, 0.1);
|
|
17
|
+
--btn-border: rgba(255, 255, 255, 0.05);
|
|
18
|
+
--btn-hover-shadow: rgba(0, 0, 0, 0.4);
|
|
19
|
+
--main-bg-gradient: radial-gradient(circle at center, #2d2d2d 0%, #1e1e1e 100%);
|
|
20
|
+
--spinner-color: rgba(255, 255, 255, 0.1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Light Theme Overrides */
|
|
24
|
+
body.vscode-light {
|
|
25
|
+
--panel-bg: rgba(255, 255, 255, 0.75);
|
|
26
|
+
--panel-border: rgba(0, 0, 0, 0.1);
|
|
27
|
+
--btn-bg: rgba(0, 0, 0, 0.05);
|
|
28
|
+
--btn-border: rgba(0, 0, 0, 0.05);
|
|
29
|
+
--btn-hover-shadow: rgba(0, 0, 0, 0.1);
|
|
30
|
+
--main-bg-gradient: radial-gradient(circle at center, #f5f5f5 0%, #ffffff 100%);
|
|
31
|
+
--spinner-color: rgba(0, 0, 0, 0.1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* High Contrast Theme Overrides */
|
|
35
|
+
body.vscode-high-contrast {
|
|
36
|
+
--panel-bg: var(--vscode-editor-background);
|
|
37
|
+
--panel-border: var(--vscode-contrastBorder);
|
|
38
|
+
--btn-bg: transparent;
|
|
39
|
+
--btn-border: var(--vscode-contrastBorder);
|
|
40
|
+
--main-bg-gradient: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
body {
|
|
44
|
+
margin: 0;
|
|
45
|
+
padding: 0;
|
|
46
|
+
background-color: var(--bg-color);
|
|
47
|
+
color: var(--text-color);
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
height: 100vh;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
font-family: var(--vscode-font-family, 'Segoe UI', system-ui, sans-serif);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.main {
|
|
56
|
+
position: relative;
|
|
57
|
+
width: 100%;
|
|
58
|
+
height: 100%;
|
|
59
|
+
display: flex;
|
|
60
|
+
justify-content: center;
|
|
61
|
+
align-items: center;
|
|
62
|
+
background-image: var(--main-bg-gradient);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.stage {
|
|
66
|
+
position: relative;
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: center;
|
|
70
|
+
gap: 10px;
|
|
71
|
+
width: 100%;
|
|
72
|
+
height: 100%;
|
|
73
|
+
min-width: 240px;
|
|
74
|
+
min-height: 180px;
|
|
75
|
+
overflow: hidden;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.viewer {
|
|
79
|
+
position: relative;
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
justify-content: center;
|
|
83
|
+
height: 100%;
|
|
84
|
+
min-width: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.controls {
|
|
88
|
+
position: relative;
|
|
89
|
+
z-index: 100;
|
|
90
|
+
display: none;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
gap: 12px;
|
|
93
|
+
padding: 12px 8px;
|
|
94
|
+
background-color: var(--vscode-editorWidget-background, var(--panel-bg));
|
|
95
|
+
backdrop-filter: blur(12px);
|
|
96
|
+
-webkit-backdrop-filter: blur(12px);
|
|
97
|
+
border-radius: 4px;
|
|
98
|
+
border: 1px solid var(--vscode-editorWidget-border, var(--panel-border));
|
|
99
|
+
transition: border-color 0.2s;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.controls:hover {
|
|
103
|
+
border-color: var(--primary-color);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.control-btn {
|
|
107
|
+
width: 32px;
|
|
108
|
+
height: 32px;
|
|
109
|
+
background-color: var(--btn-bg);
|
|
110
|
+
color: var(--text-color);
|
|
111
|
+
border: 1px solid var(--btn-border);
|
|
112
|
+
border-radius: 50%;
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
display: flex;
|
|
115
|
+
justify-content: center;
|
|
116
|
+
align-items: center;
|
|
117
|
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
118
|
+
position: relative;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.control-btn:focus-visible {
|
|
122
|
+
outline: 2px solid var(--primary-color);
|
|
123
|
+
outline-offset: 2px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.control-btn:hover {
|
|
127
|
+
background-color: var(--primary-color);
|
|
128
|
+
border-color: var(--primary-color);
|
|
129
|
+
color: #ffffff;
|
|
130
|
+
transform: scale(1.1);
|
|
131
|
+
box-shadow: none;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.control-btn:active {
|
|
135
|
+
transform: scale(0.92);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.control-btn svg {
|
|
139
|
+
width: 16px;
|
|
140
|
+
height: 16px;
|
|
141
|
+
fill: currentColor;
|
|
142
|
+
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.live-image {
|
|
146
|
+
display: block;
|
|
147
|
+
max-width: 100%;
|
|
148
|
+
max-height: 100%;
|
|
149
|
+
object-fit: contain;
|
|
150
|
+
cursor: crosshair;
|
|
151
|
+
box-shadow: none;
|
|
152
|
+
transition: opacity 0.3s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.loading-container {
|
|
156
|
+
position: absolute;
|
|
157
|
+
top: 50%;
|
|
158
|
+
left: 50%;
|
|
159
|
+
transform: translate(-50%, -50%);
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-direction: column;
|
|
162
|
+
align-items: center;
|
|
163
|
+
justify-content: center;
|
|
164
|
+
width: 240px;
|
|
165
|
+
height: 180px;
|
|
166
|
+
gap: 16px;
|
|
167
|
+
z-index: 50;
|
|
168
|
+
border-radius: 4px;
|
|
169
|
+
background: var(--vscode-editorWidget-background, var(--panel-bg));
|
|
170
|
+
border: 1px solid var(--vscode-editorWidget-border, var(--panel-border));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.spinner {
|
|
174
|
+
width: 40px;
|
|
175
|
+
height: 40px;
|
|
176
|
+
border: 3px solid var(--spinner-color);
|
|
177
|
+
border-radius: 50%;
|
|
178
|
+
border-top-color: var(--primary-color);
|
|
179
|
+
animation: spin 1s ease-in-out infinite;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@keyframes spin {
|
|
183
|
+
to {
|
|
184
|
+
transform: rotate(360deg);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.loading-text {
|
|
189
|
+
color: var(--vscode-descriptionForeground, #888);
|
|
190
|
+
font-size: 14px;
|
|
191
|
+
letter-spacing: 1px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.error {
|
|
195
|
+
display: none;
|
|
196
|
+
position: absolute;
|
|
197
|
+
top: 50%;
|
|
198
|
+
left: 50%;
|
|
199
|
+
transform: translate(-50%, -50%);
|
|
200
|
+
width: 240px;
|
|
201
|
+
height: 180px;
|
|
202
|
+
flex-direction: column;
|
|
203
|
+
align-items: center;
|
|
204
|
+
justify-content: center;
|
|
205
|
+
color: var(--danger-color);
|
|
206
|
+
font-size: 16px;
|
|
207
|
+
text-align: center;
|
|
208
|
+
background: var(--vscode-editorWidget-background, var(--panel-bg));
|
|
209
|
+
border: 1px solid var(--vscode-editorWidget-border, var(--panel-border));
|
|
210
|
+
border-radius: 4px;
|
|
211
|
+
backdrop-filter: blur(4px);
|
|
212
|
+
z-index: 60;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.click-indicator {
|
|
216
|
+
position: fixed;
|
|
217
|
+
width: 24px;
|
|
218
|
+
height: 24px;
|
|
219
|
+
border: 2px solid var(--primary-color);
|
|
220
|
+
border-radius: 50%;
|
|
221
|
+
pointer-events: none;
|
|
222
|
+
opacity: 0;
|
|
223
|
+
transition:
|
|
224
|
+
opacity 0.3s,
|
|
225
|
+
transform 0.2s;
|
|
226
|
+
z-index: 9999;
|
|
227
|
+
box-shadow: 0 0 10px rgba(0, 122, 204, 0.5);
|
|
228
|
+
transform: translate(-50%, -50%);
|
|
229
|
+
}
|
|
230
|
+
</style>
|
|
231
|
+
</head>
|
|
232
|
+
|
|
233
|
+
<body>
|
|
234
|
+
<div class="main">
|
|
235
|
+
<div class="stage" id="stage">
|
|
236
|
+
<div class="controls">
|
|
237
|
+
<button class="control-btn" id="volumeUpBtn" title="音量+">
|
|
238
|
+
<svg viewBox="0 0 24 24">
|
|
239
|
+
<path
|
|
240
|
+
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
|
241
|
+
/>
|
|
242
|
+
</svg>
|
|
243
|
+
</button>
|
|
244
|
+
<button class="control-btn" id="volumeDownBtn" title="音量-">
|
|
245
|
+
<svg viewBox="0 0 24 24">
|
|
246
|
+
<path
|
|
247
|
+
d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"
|
|
248
|
+
/>
|
|
249
|
+
</svg>
|
|
250
|
+
</button>
|
|
251
|
+
<button class="control-btn" id="appSwitcherBtn" title="应用切换器">
|
|
252
|
+
<svg viewBox="0 0 24 24">
|
|
253
|
+
<path d="M7 5h12v12H7V5zm2 2v8h8V7H9zM5 9H3v10c0 1.1.9 2 2 2h10v-2H5V9z" />
|
|
254
|
+
</svg>
|
|
255
|
+
</button>
|
|
256
|
+
<button class="control-btn" id="homeBtn" title="返回桌面">
|
|
257
|
+
<svg viewBox="0 0 24 24">
|
|
258
|
+
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
|
259
|
+
</svg>
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div class="viewer" id="viewer">
|
|
264
|
+
<div class="loading-container" id="loading">
|
|
265
|
+
<div class="spinner"></div>
|
|
266
|
+
<div class="loading-text">正在连接设备...</div>
|
|
267
|
+
</div>
|
|
268
|
+
<input
|
|
269
|
+
id="hiddenInput"
|
|
270
|
+
type="text"
|
|
271
|
+
style="position: absolute; width: 1px; height: 1px; opacity: 0; border: none"
|
|
272
|
+
/>
|
|
273
|
+
<img
|
|
274
|
+
draggable="false"
|
|
275
|
+
class="live-image"
|
|
276
|
+
id="liveImage"
|
|
277
|
+
alt="实时画面"
|
|
278
|
+
style="display: none"
|
|
279
|
+
/>
|
|
280
|
+
<div class="error" id="error">连接失败</div>
|
|
281
|
+
<div class="click-indicator" id="clickIndicator"></div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<script>
|
|
287
|
+
const vscode = acquireVsCodeApi();
|
|
288
|
+
const image = document.getElementById('liveImage');
|
|
289
|
+
const loading = document.getElementById('loading');
|
|
290
|
+
const error = document.getElementById('error');
|
|
291
|
+
const homeBtn = document.getElementById('homeBtn');
|
|
292
|
+
const appSwitcherBtn = document.getElementById('appSwitcherBtn');
|
|
293
|
+
const clickIndicator = document.getElementById('clickIndicator');
|
|
294
|
+
const hiddenInput = document.getElementById('hiddenInput');
|
|
295
|
+
|
|
296
|
+
const STREAM_URL = '{{IMAGE_URL}}';
|
|
297
|
+
|
|
298
|
+
function send(command, payload = {}) {
|
|
299
|
+
vscode.postMessage({ command, ...payload });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function setState(next) {
|
|
303
|
+
const showLoading = next === 'loading';
|
|
304
|
+
const showError = next === 'error';
|
|
305
|
+
const showImage = next === 'ok';
|
|
306
|
+
const showControls = next === 'ok';
|
|
307
|
+
|
|
308
|
+
loading.style.display = showLoading ? 'flex' : 'none';
|
|
309
|
+
error.style.display = showError ? 'flex' : 'none';
|
|
310
|
+
image.style.display = showImage ? 'block' : 'none';
|
|
311
|
+
|
|
312
|
+
const controlsEl = document.querySelector('.controls');
|
|
313
|
+
if (controlsEl) {
|
|
314
|
+
controlsEl.style.display = showControls ? 'flex' : 'none';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- MjpegStream Class Implementation ---
|
|
319
|
+
class MjpegStream {
|
|
320
|
+
constructor(options) {
|
|
321
|
+
this.controller = null;
|
|
322
|
+
this.stallTimer = null;
|
|
323
|
+
this.reconnectTimer = null;
|
|
324
|
+
this.currentObjectUrl = '';
|
|
325
|
+
this.runId = 0;
|
|
326
|
+
this.reconnectDelayMs = 1000;
|
|
327
|
+
this.isActive = false;
|
|
328
|
+
this.options = options;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
updateUrl(url) {
|
|
332
|
+
if (this.options.url !== url) {
|
|
333
|
+
this.options.url = url;
|
|
334
|
+
if (this.isActive) {
|
|
335
|
+
this.start();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
start() {
|
|
341
|
+
this.isActive = true;
|
|
342
|
+
this.stopInternal(false);
|
|
343
|
+
this.startInternal();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
stop() {
|
|
347
|
+
this.isActive = false;
|
|
348
|
+
this.stopInternal(true);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
stopInternal(cleanup) {
|
|
352
|
+
this.runId++;
|
|
353
|
+
|
|
354
|
+
if (this.reconnectTimer) {
|
|
355
|
+
clearTimeout(this.reconnectTimer);
|
|
356
|
+
this.reconnectTimer = null;
|
|
357
|
+
}
|
|
358
|
+
if (this.stallTimer) {
|
|
359
|
+
clearInterval(this.stallTimer);
|
|
360
|
+
this.stallTimer = null;
|
|
361
|
+
}
|
|
362
|
+
if (this.controller) {
|
|
363
|
+
this.controller.abort();
|
|
364
|
+
this.controller = null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (cleanup && this.currentObjectUrl) {
|
|
368
|
+
URL.revokeObjectURL(this.currentObjectUrl);
|
|
369
|
+
this.currentObjectUrl = '';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async startInternal() {
|
|
374
|
+
const myId = this.runId;
|
|
375
|
+
|
|
376
|
+
if (!this.options.url) {
|
|
377
|
+
this.options.onSuccess?.();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!this.currentObjectUrl) {
|
|
382
|
+
this.options.onLoading?.();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.controller = new AbortController();
|
|
386
|
+
const ts = Date.now();
|
|
387
|
+
const url = this.options.url + (this.options.url.includes('?') ? '&' : '?') + '_ts=' + ts;
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const res = await fetch(url, {
|
|
391
|
+
signal: this.controller.signal,
|
|
392
|
+
cache: 'no-store',
|
|
393
|
+
headers: {
|
|
394
|
+
Accept: 'multipart/x-mixed-replace'
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (myId !== this.runId) return;
|
|
399
|
+
if (!res.ok || !res.body) {
|
|
400
|
+
throw new Error('stream_failed');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.options.onSuccess?.();
|
|
404
|
+
|
|
405
|
+
let lastChunkAt = Date.now();
|
|
406
|
+
this.stallTimer = window.setInterval(() => {
|
|
407
|
+
if (!this.controller) return;
|
|
408
|
+
if (Date.now() - lastChunkAt > 5000) {
|
|
409
|
+
this.controller.abort();
|
|
410
|
+
}
|
|
411
|
+
}, 1000);
|
|
412
|
+
|
|
413
|
+
const reader = res.body.getReader();
|
|
414
|
+
let buffer = new Uint8Array(0);
|
|
415
|
+
|
|
416
|
+
while (true) {
|
|
417
|
+
if (myId !== this.runId) return;
|
|
418
|
+
const { value, done } = await reader.read();
|
|
419
|
+
if (done) {
|
|
420
|
+
throw new Error('stream_ended');
|
|
421
|
+
}
|
|
422
|
+
if (!value || value.length === 0) continue;
|
|
423
|
+
|
|
424
|
+
lastChunkAt = Date.now();
|
|
425
|
+
buffer = this.concatBytes(buffer, value);
|
|
426
|
+
|
|
427
|
+
if (buffer.length > 5_000_000) {
|
|
428
|
+
buffer = buffer.slice(buffer.length - 1_000_000);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
while (true) {
|
|
432
|
+
if (myId !== this.runId) return;
|
|
433
|
+
const start = this.findJpegStart(buffer, 0);
|
|
434
|
+
if (start < 0) {
|
|
435
|
+
if (buffer.length > 1_000_000) {
|
|
436
|
+
buffer = buffer.slice(buffer.length - 100_000);
|
|
437
|
+
}
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
const end = this.findJpegEnd(buffer, start + 2);
|
|
441
|
+
if (end < 0) {
|
|
442
|
+
if (start > 0) buffer = buffer.slice(start);
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const frame = buffer.slice(start, end);
|
|
447
|
+
this.setFrame(frame);
|
|
448
|
+
buffer = buffer.slice(end);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch (e) {
|
|
452
|
+
if (myId === this.runId) {
|
|
453
|
+
this.handleError();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
setFrame(bytes) {
|
|
459
|
+
const blob = new Blob([bytes], { type: 'image/jpeg' });
|
|
460
|
+
const url = URL.createObjectURL(blob);
|
|
461
|
+
|
|
462
|
+
if (this.currentObjectUrl) {
|
|
463
|
+
URL.revokeObjectURL(this.currentObjectUrl);
|
|
464
|
+
}
|
|
465
|
+
this.currentObjectUrl = url;
|
|
466
|
+
this.options.onFrame(url);
|
|
467
|
+
this.reconnectDelayMs = 2000;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
handleError() {
|
|
471
|
+
if (this.stallTimer) {
|
|
472
|
+
clearInterval(this.stallTimer);
|
|
473
|
+
this.stallTimer = null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (this.controller) {
|
|
477
|
+
this.controller.abort();
|
|
478
|
+
this.controller = null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (this.currentObjectUrl) {
|
|
482
|
+
URL.revokeObjectURL(this.currentObjectUrl);
|
|
483
|
+
this.currentObjectUrl = '';
|
|
484
|
+
}
|
|
485
|
+
this.options.onError?.();
|
|
486
|
+
this.scheduleReconnect();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
scheduleReconnect() {
|
|
490
|
+
if (this.reconnectTimer) return;
|
|
491
|
+
const myId = this.runId;
|
|
492
|
+
this.reconnectTimer = window.setTimeout(() => {
|
|
493
|
+
this.reconnectTimer = null;
|
|
494
|
+
if (myId !== this.runId) return;
|
|
495
|
+
this.startInternal();
|
|
496
|
+
}, this.reconnectDelayMs);
|
|
497
|
+
this.reconnectDelayMs = Math.min(Math.round(this.reconnectDelayMs * 1.6), 4000);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
concatBytes(a, b) {
|
|
501
|
+
const out = new Uint8Array(a.length + b.length);
|
|
502
|
+
out.set(a, 0);
|
|
503
|
+
out.set(b, a.length);
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
findJpegStart(buf, from) {
|
|
508
|
+
for (let i = from; i + 1 < buf.length; i++) {
|
|
509
|
+
if (buf[i] === 0xff && buf[i + 1] === 0xd8) return i;
|
|
510
|
+
}
|
|
511
|
+
return -1;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
findJpegEnd(buf, from) {
|
|
515
|
+
for (let i = from; i + 1 < buf.length; i++) {
|
|
516
|
+
if (buf[i] === 0xff && buf[i + 1] === 0xd9) return i + 2;
|
|
517
|
+
}
|
|
518
|
+
return -1;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 监听组合输入事件
|
|
523
|
+
let isComposing = false;
|
|
524
|
+
|
|
525
|
+
hiddenInput.addEventListener('compositionstart', () => {
|
|
526
|
+
isComposing = true; // 开始组合输入
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
hiddenInput.addEventListener('compositionend', (e) => {
|
|
530
|
+
isComposing = false; // 结束组合输入
|
|
531
|
+
const val = e.target.value;
|
|
532
|
+
if (val.length > 0) {
|
|
533
|
+
send('input', { text: val });
|
|
534
|
+
e.target.value = '';
|
|
535
|
+
hiddenInput.focus();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
let lastBackspaceTime = 0;
|
|
540
|
+
const BACKSPACE_THROTTLE = 333; // 333ms节流间隔
|
|
541
|
+
hiddenInput.addEventListener('beforeinput', (event) => {
|
|
542
|
+
if (event.inputType === 'deleteContentBackward') {
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
if (now - lastBackspaceTime > BACKSPACE_THROTTLE) {
|
|
545
|
+
lastBackspaceTime = now;
|
|
546
|
+
send('backspace', { count: 1 });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
let inputBuffer = '';
|
|
552
|
+
let lastSendTime = 0;
|
|
553
|
+
const INPUT_THROTTLE = 333; // 333ms节流间隔
|
|
554
|
+
hiddenInput.addEventListener('input', (e) => {
|
|
555
|
+
if (isComposing) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const val = e.target.value;
|
|
560
|
+
if (val.length > 0) {
|
|
561
|
+
inputBuffer += val;
|
|
562
|
+
|
|
563
|
+
e.target.value = '';
|
|
564
|
+
hiddenInput.focus();
|
|
565
|
+
|
|
566
|
+
const now = Date.now();
|
|
567
|
+
if (now - lastSendTime >= INPUT_THROTTLE) {
|
|
568
|
+
if (inputBuffer.length > 0) {
|
|
569
|
+
send('input', { text: inputBuffer });
|
|
570
|
+
inputBuffer = ''; // 清空缓冲区
|
|
571
|
+
lastSendTime = now; // 更新发送时间
|
|
572
|
+
}
|
|
573
|
+
} else if (inputBuffer.length > 0) {
|
|
574
|
+
setTimeout(
|
|
575
|
+
() => {
|
|
576
|
+
if (inputBuffer.length > 0) {
|
|
577
|
+
send('input', { text: inputBuffer });
|
|
578
|
+
inputBuffer = '';
|
|
579
|
+
lastSendTime = Date.now();
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
INPUT_THROTTLE - (now - lastSendTime)
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
hiddenInput.addEventListener('keyup', (event) => {
|
|
589
|
+
if (event.key === 'Enter') {
|
|
590
|
+
send('enter');
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Mjpeg Stream Initialization
|
|
595
|
+
let hasLoadedOnce = false;
|
|
596
|
+
const stream = new MjpegStream({
|
|
597
|
+
url: STREAM_URL,
|
|
598
|
+
onFrame: (url) => {
|
|
599
|
+
image.src = url;
|
|
600
|
+
},
|
|
601
|
+
onLoading: () => {
|
|
602
|
+
setState('loading');
|
|
603
|
+
},
|
|
604
|
+
onSuccess: () => {
|
|
605
|
+
setState('ok');
|
|
606
|
+
|
|
607
|
+
if (!hasLoadedOnce) {
|
|
608
|
+
hasLoadedOnce = true;
|
|
609
|
+
send('loaded', { text: '实时画面加载成功' });
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
onError: () => {
|
|
613
|
+
setState('error');
|
|
614
|
+
send('error', { text: '无法连接到设备实时画面' });
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
setState('loading');
|
|
619
|
+
|
|
620
|
+
// Start the stream
|
|
621
|
+
stream.start();
|
|
622
|
+
|
|
623
|
+
// Clean up on page unload (though WebView usually destroys the whole context)
|
|
624
|
+
window.addEventListener('unload', () => {
|
|
625
|
+
stream.stop();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// --- Coordinate Mapping Helper ---
|
|
629
|
+
function mapCoords(event, img) {
|
|
630
|
+
const rect = img.getBoundingClientRect();
|
|
631
|
+
const naturalWidth = img.naturalWidth;
|
|
632
|
+
const naturalHeight = img.naturalHeight;
|
|
633
|
+
|
|
634
|
+
// Calculate the actual display scale (object-fit: contain)
|
|
635
|
+
const scale = Math.min(rect.width / naturalWidth, rect.height / naturalHeight);
|
|
636
|
+
|
|
637
|
+
// Calculate the actual display dimensions
|
|
638
|
+
const displayWidth = naturalWidth * scale;
|
|
639
|
+
const displayHeight = naturalHeight * scale;
|
|
640
|
+
|
|
641
|
+
// Calculate offsets (centering)
|
|
642
|
+
const offsetX = (rect.width - displayWidth) / 2;
|
|
643
|
+
const offsetY = (rect.height - displayHeight) / 2;
|
|
644
|
+
|
|
645
|
+
// Calculate coordinates relative to the image content
|
|
646
|
+
const clientX = event.clientX - rect.left;
|
|
647
|
+
const clientY = event.clientY - rect.top;
|
|
648
|
+
|
|
649
|
+
let x = (clientX - offsetX) / scale;
|
|
650
|
+
let y = (clientY - offsetY) / scale;
|
|
651
|
+
|
|
652
|
+
// Clamp coordinates within valid range
|
|
653
|
+
x = Math.max(0, Math.min(x, naturalWidth));
|
|
654
|
+
y = Math.max(0, Math.min(y, naturalHeight));
|
|
655
|
+
|
|
656
|
+
return { x: Math.round(x), y: Math.round(y) };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// 添加 mousemove 事件来检测滑动
|
|
660
|
+
image.addEventListener('mousemove', function (event) {
|
|
661
|
+
if (!isMouseDown) return;
|
|
662
|
+
|
|
663
|
+
const { x: currentX, y: currentY } = mapCoords(event, image);
|
|
664
|
+
|
|
665
|
+
// 计算移动距离
|
|
666
|
+
const deltaX = Math.abs(currentX - startX);
|
|
667
|
+
const deltaY = Math.abs(currentY - startY);
|
|
668
|
+
|
|
669
|
+
// 如果移动距离超过长按阈值,取消长按定时器
|
|
670
|
+
if ((deltaX > LONG_PRESS_THRESHOLD || deltaY > LONG_PRESS_THRESHOLD) && longPressTimer) {
|
|
671
|
+
clearTimeout(longPressTimer);
|
|
672
|
+
longPressTimer = null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// 如果移动距离超过滑动阈值,则认为是滑动
|
|
676
|
+
if ((deltaX > SWIPE_THRESHOLD || deltaY > SWIPE_THRESHOLD) && !isDragging) {
|
|
677
|
+
isDragging = true;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 如果正在拖拽,显示跟随鼠标的指示器
|
|
681
|
+
if (isDragging) {
|
|
682
|
+
showClickIndicator(event.clientX, event.clientY, '#28a745', true); // 绿色表示拖拽,持续显示
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// 图片点击事件 - 支持长按检测和滑动
|
|
687
|
+
let mouseDownTime = 0;
|
|
688
|
+
let longPressTimer = null;
|
|
689
|
+
let isLongPress = false;
|
|
690
|
+
let isMouseDown = false;
|
|
691
|
+
let startX = 0;
|
|
692
|
+
let startY = 0;
|
|
693
|
+
let isDragging = false;
|
|
694
|
+
const SWIPE_THRESHOLD = 3; // 滑动阈值(像素)- 降低阈值提高敏感度
|
|
695
|
+
const LONG_PRESS_THRESHOLD = 8; // 长按容忍阈值(像素)- 允许轻微移动
|
|
696
|
+
|
|
697
|
+
image.addEventListener('click', function (event) {
|
|
698
|
+
hiddenInput.focus();
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
image.addEventListener('mousedown', function (event) {
|
|
702
|
+
mouseDownTime = Date.now();
|
|
703
|
+
isLongPress = false;
|
|
704
|
+
isMouseDown = true;
|
|
705
|
+
isDragging = false;
|
|
706
|
+
|
|
707
|
+
const { x, y } = mapCoords(event, image);
|
|
708
|
+
|
|
709
|
+
// 记录起始位置(屏幕坐标和图片坐标)
|
|
710
|
+
startX = x;
|
|
711
|
+
startY = y;
|
|
712
|
+
|
|
713
|
+
// 立即显示点击指示器(蓝色)
|
|
714
|
+
showClickIndicator(event.clientX, event.clientY, '#007acc');
|
|
715
|
+
|
|
716
|
+
// 设置长按定时器
|
|
717
|
+
longPressTimer = setTimeout(() => {
|
|
718
|
+
if (!isDragging && isMouseDown) {
|
|
719
|
+
// 只有在没有拖拽且仍在按下状态时才触发长按
|
|
720
|
+
isLongPress = true;
|
|
721
|
+
// const duration = Date.now() - mouseDownTime;
|
|
722
|
+
|
|
723
|
+
// 长按时将指示器变为红色
|
|
724
|
+
showClickIndicator(event.clientX, event.clientY, '#d73a49');
|
|
725
|
+
|
|
726
|
+
// 发送长按事件到 VS Code
|
|
727
|
+
send('longPress', { x, y, duration: 600 });
|
|
728
|
+
}
|
|
729
|
+
}, 200);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
image.addEventListener('mouseup', function (event) {
|
|
733
|
+
if (!isMouseDown) return;
|
|
734
|
+
|
|
735
|
+
const duration = Date.now() - mouseDownTime;
|
|
736
|
+
|
|
737
|
+
// 清除长按定时器
|
|
738
|
+
if (longPressTimer) {
|
|
739
|
+
clearTimeout(longPressTimer);
|
|
740
|
+
longPressTimer = null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const { x: endX, y: endY } = mapCoords(event, image);
|
|
744
|
+
|
|
745
|
+
// 如果是滑动操作
|
|
746
|
+
if (isDragging) {
|
|
747
|
+
send('swipe', { startX, startY, endX, endY });
|
|
748
|
+
}
|
|
749
|
+
// 如果不是长按且不是滑动,则发送普通点击事件
|
|
750
|
+
else if (!isLongPress && duration < 500) {
|
|
751
|
+
send('click', { x: endX, y: endY });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// 隐藏指示器并重置状态
|
|
755
|
+
hideClickIndicator();
|
|
756
|
+
mouseDownTime = 0;
|
|
757
|
+
isLongPress = false;
|
|
758
|
+
isMouseDown = false;
|
|
759
|
+
isDragging = false;
|
|
760
|
+
startX = 0;
|
|
761
|
+
startY = 0;
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// 处理鼠标离开图片区域的情况
|
|
765
|
+
image.addEventListener('mouseleave', function () {
|
|
766
|
+
if (longPressTimer) {
|
|
767
|
+
clearTimeout(longPressTimer);
|
|
768
|
+
longPressTimer = null;
|
|
769
|
+
}
|
|
770
|
+
// 隐藏指示器并重置所有状态
|
|
771
|
+
hideClickIndicator();
|
|
772
|
+
mouseDownTime = 0;
|
|
773
|
+
isLongPress = false;
|
|
774
|
+
isMouseDown = false;
|
|
775
|
+
isDragging = false;
|
|
776
|
+
startX = 0;
|
|
777
|
+
startY = 0;
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// 音量按钮事件监听
|
|
781
|
+
document.getElementById('volumeUpBtn').addEventListener('click', function () {
|
|
782
|
+
send('volumeUp');
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
document.getElementById('volumeDownBtn').addEventListener('click', function () {
|
|
786
|
+
send('volumeDown');
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
homeBtn.addEventListener('click', function () {
|
|
790
|
+
send('home');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
appSwitcherBtn.addEventListener('click', function () {
|
|
794
|
+
send('recent');
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
let indicatorTimer = null;
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* 显示点击指示器
|
|
801
|
+
* @param {number} clientX - 点击位置的 X 坐标
|
|
802
|
+
* @param {number} clientY - 点击位置的 Y 坐标
|
|
803
|
+
* @param {string} color - 指示器颜色(可选,默认为蓝色)
|
|
804
|
+
* @param {boolean} persistent - 是否持续显示(拖拽时使用)
|
|
805
|
+
*/
|
|
806
|
+
function showClickIndicator(clientX, clientY, color = '#007acc', persistent = false) {
|
|
807
|
+
clickIndicator.style.left = clientX + 'px';
|
|
808
|
+
clickIndicator.style.top = clientY + 'px';
|
|
809
|
+
clickIndicator.style.borderColor = color;
|
|
810
|
+
clickIndicator.style.opacity = '1';
|
|
811
|
+
|
|
812
|
+
// 清除之前的定时器
|
|
813
|
+
if (indicatorTimer) {
|
|
814
|
+
clearTimeout(indicatorTimer);
|
|
815
|
+
indicatorTimer = null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// 如果不是持续显示,设置定时器隐藏
|
|
819
|
+
if (!persistent) {
|
|
820
|
+
indicatorTimer = setTimeout(() => {
|
|
821
|
+
clickIndicator.style.opacity = '0';
|
|
822
|
+
indicatorTimer = null;
|
|
823
|
+
}, 300);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* 隐藏点击指示器
|
|
829
|
+
*/
|
|
830
|
+
function hideClickIndicator() {
|
|
831
|
+
if (indicatorTimer) {
|
|
832
|
+
clearTimeout(indicatorTimer);
|
|
833
|
+
indicatorTimer = null;
|
|
834
|
+
}
|
|
835
|
+
clickIndicator.style.opacity = '0';
|
|
836
|
+
}
|
|
837
|
+
</script>
|
|
838
|
+
</body>
|
|
839
|
+
</html>
|