@marineyachtradar/signalk-playback-plugin 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -13
- package/package.json +3 -3
- package/public/api.js +402 -0
- package/public/assets/MaYaRa_RED.png +0 -0
- package/public/base.css +91 -0
- package/public/control.html +23 -0
- package/public/control.js +1155 -0
- package/public/controls.css +538 -0
- package/public/discovery.css +478 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +10 -0
- package/public/layout.css +87 -0
- package/public/mayara.js +510 -0
- package/public/playback.html +572 -0
- package/public/proto/RadarMessage.proto +41 -0
- package/public/protobuf/protobuf.js +9112 -0
- package/public/protobuf/protobuf.js.map +1 -0
- package/public/protobuf/protobuf.min.js +8 -0
- package/public/protobuf/protobuf.min.js.map +1 -0
- package/public/radar.svg +29 -0
- package/public/render_webgpu.js +886 -0
- package/public/responsive.css +29 -0
- package/public/van-1.5.2.debug.js +126 -0
- package/public/van-1.5.2.js +140 -0
- package/public/van-1.5.2.min.js +1 -0
- package/public/viewer.html +30 -0
- package/public/viewer.js +797 -0
- package/build.js +0 -248
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>MaYaRa Radar Playback</title>
|
|
7
|
+
<link rel="stylesheet" href="base.css">
|
|
8
|
+
<link rel="stylesheet" href="discovery.css">
|
|
9
|
+
<style>
|
|
10
|
+
.playback_container {
|
|
11
|
+
max-width: 800px;
|
|
12
|
+
margin: 0 auto;
|
|
13
|
+
padding: 20px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.playback_header {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 20px;
|
|
20
|
+
margin-bottom: 30px;
|
|
21
|
+
padding-bottom: 20px;
|
|
22
|
+
border-bottom: 1px solid rgba(100, 200, 180, 0.3);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.playback_header h1 {
|
|
26
|
+
margin: 0;
|
|
27
|
+
font-size: 28px;
|
|
28
|
+
color: rgb(100, 200, 180);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.playback_section {
|
|
32
|
+
margin-bottom: 25px;
|
|
33
|
+
padding: 20px;
|
|
34
|
+
background: rgba(3, 37, 37, 0.8);
|
|
35
|
+
border-radius: 8px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.playback_section h2 {
|
|
39
|
+
margin: 0 0 15px 0;
|
|
40
|
+
font-size: 18px;
|
|
41
|
+
color: rgb(100, 200, 180);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.file_list {
|
|
45
|
+
max-height: 300px;
|
|
46
|
+
overflow-y: auto;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.file_item {
|
|
50
|
+
display: flex;
|
|
51
|
+
justify-content: space-between;
|
|
52
|
+
align-items: center;
|
|
53
|
+
padding: 12px 15px;
|
|
54
|
+
border-bottom: 1px solid rgba(100, 200, 180, 0.1);
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
transition: background 0.2s ease;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.file_item:hover {
|
|
60
|
+
background: rgba(100, 200, 180, 0.1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.file_item.selected {
|
|
64
|
+
background: rgba(100, 200, 180, 0.2);
|
|
65
|
+
border-left: 3px solid rgb(100, 200, 180);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.file_name {
|
|
69
|
+
font-weight: bold;
|
|
70
|
+
color: rgb(152, 217, 204);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.file_meta {
|
|
74
|
+
font-size: 12px;
|
|
75
|
+
color: rgb(120, 160, 150);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.file_actions button {
|
|
79
|
+
padding: 6px 12px;
|
|
80
|
+
margin-left: 8px;
|
|
81
|
+
background: transparent;
|
|
82
|
+
color: rgb(152, 217, 204);
|
|
83
|
+
border: 1px solid rgba(100, 200, 180, 0.3);
|
|
84
|
+
border-radius: 4px;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
font-size: 12px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.file_actions button:hover {
|
|
90
|
+
background: rgba(100, 200, 180, 0.1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.file_actions button.delete {
|
|
94
|
+
color: rgb(200, 100, 100);
|
|
95
|
+
border-color: rgba(200, 100, 100, 0.3);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.file_actions button.delete:hover {
|
|
99
|
+
background: rgba(200, 100, 100, 0.1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.upload_zone {
|
|
103
|
+
border: 2px dashed rgba(100, 200, 180, 0.3);
|
|
104
|
+
border-radius: 8px;
|
|
105
|
+
padding: 30px;
|
|
106
|
+
text-align: center;
|
|
107
|
+
color: rgb(120, 160, 150);
|
|
108
|
+
transition: all 0.2s ease;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.upload_zone:hover, .upload_zone.dragover {
|
|
113
|
+
border-color: rgb(100, 200, 180);
|
|
114
|
+
background: rgba(100, 200, 180, 0.1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.upload_zone input {
|
|
118
|
+
display: none;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.controls {
|
|
122
|
+
display: flex;
|
|
123
|
+
gap: 10px;
|
|
124
|
+
align-items: center;
|
|
125
|
+
flex-wrap: wrap;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.controls button {
|
|
129
|
+
padding: 10px 20px;
|
|
130
|
+
font-size: 14px;
|
|
131
|
+
font-weight: bold;
|
|
132
|
+
border: none;
|
|
133
|
+
border-radius: 6px;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
transition: all 0.2s ease;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.controls button:disabled {
|
|
139
|
+
opacity: 0.5;
|
|
140
|
+
cursor: not-allowed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.btn_play {
|
|
144
|
+
background: rgb(100, 200, 180);
|
|
145
|
+
color: #000;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.btn_play:hover:not(:disabled) {
|
|
149
|
+
background: rgb(130, 230, 210);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.btn_pause {
|
|
153
|
+
background: rgba(255, 200, 100, 0.8);
|
|
154
|
+
color: #000;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.btn_stop {
|
|
158
|
+
background: rgba(200, 100, 100, 0.8);
|
|
159
|
+
color: #fff;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.status_info {
|
|
163
|
+
margin-top: 20px;
|
|
164
|
+
padding: 15px;
|
|
165
|
+
background: rgba(0, 0, 0, 0.3);
|
|
166
|
+
border-radius: 6px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.status_row {
|
|
170
|
+
display: flex;
|
|
171
|
+
justify-content: space-between;
|
|
172
|
+
padding: 5px 0;
|
|
173
|
+
color: rgb(180, 200, 200);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.status_label {
|
|
177
|
+
color: rgb(120, 160, 150);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.view_link {
|
|
181
|
+
display: inline-block;
|
|
182
|
+
margin-top: 15px;
|
|
183
|
+
padding: 10px 20px;
|
|
184
|
+
background: rgb(100, 200, 180);
|
|
185
|
+
color: #000;
|
|
186
|
+
text-decoration: none;
|
|
187
|
+
border-radius: 6px;
|
|
188
|
+
font-weight: bold;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.view_link:hover {
|
|
192
|
+
background: rgb(130, 230, 210);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.checkbox_label {
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
gap: 8px;
|
|
199
|
+
color: rgb(180, 200, 200);
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.no_files {
|
|
204
|
+
padding: 30px;
|
|
205
|
+
text-align: center;
|
|
206
|
+
color: rgb(120, 160, 150);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.message {
|
|
210
|
+
position: fixed;
|
|
211
|
+
bottom: 20px;
|
|
212
|
+
left: 50%;
|
|
213
|
+
transform: translateX(-50%);
|
|
214
|
+
padding: 12px 24px;
|
|
215
|
+
border-radius: 8px;
|
|
216
|
+
font-weight: bold;
|
|
217
|
+
z-index: 1000;
|
|
218
|
+
display: none;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.message.success {
|
|
222
|
+
background: rgba(100, 200, 100, 0.9);
|
|
223
|
+
color: #000;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.message.error {
|
|
227
|
+
background: rgba(200, 100, 100, 0.9);
|
|
228
|
+
color: #fff;
|
|
229
|
+
}
|
|
230
|
+
</style>
|
|
231
|
+
</head>
|
|
232
|
+
<body class="myr_discovery_body">
|
|
233
|
+
<div class="playback_container">
|
|
234
|
+
<div class="playback_header">
|
|
235
|
+
<h1>🎬 Radar Playback</h1>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<!-- Upload Section -->
|
|
239
|
+
<div class="playback_section">
|
|
240
|
+
<h2>Upload Recording</h2>
|
|
241
|
+
<div class="upload_zone" id="upload_zone">
|
|
242
|
+
<p>Drop .mrr or .mrr.gz file here</p>
|
|
243
|
+
<p>or click to browse</p>
|
|
244
|
+
<input type="file" id="file_input" accept=".mrr,.mrr.gz">
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<!-- Recordings List -->
|
|
249
|
+
<div class="playback_section">
|
|
250
|
+
<h2>Available Recordings</h2>
|
|
251
|
+
<div class="file_list" id="file_list">
|
|
252
|
+
<div class="no_files">Loading...</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<!-- Playback Controls -->
|
|
257
|
+
<div class="playback_section">
|
|
258
|
+
<h2>Playback</h2>
|
|
259
|
+
<div class="controls">
|
|
260
|
+
<button class="btn_play" id="btn_play" disabled>▶ Play</button>
|
|
261
|
+
<button class="btn_pause" id="btn_pause" disabled>⏸ Pause</button>
|
|
262
|
+
<button class="btn_stop" id="btn_stop" disabled>⏹ Stop</button>
|
|
263
|
+
<label class="checkbox_label">
|
|
264
|
+
<input type="checkbox" id="loop_checkbox" checked>
|
|
265
|
+
Loop
|
|
266
|
+
</label>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div class="status_info" id="status_info" style="display: none;">
|
|
270
|
+
<div class="status_row">
|
|
271
|
+
<span class="status_label">File:</span>
|
|
272
|
+
<span id="status_filename">-</span>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="status_row">
|
|
275
|
+
<span class="status_label">Status:</span>
|
|
276
|
+
<span id="status_state">-</span>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="status_row">
|
|
279
|
+
<span class="status_label">Position:</span>
|
|
280
|
+
<span id="status_position">-</span>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="status_row">
|
|
283
|
+
<span class="status_label">Frame:</span>
|
|
284
|
+
<span id="status_frame">-</span>
|
|
285
|
+
</div>
|
|
286
|
+
<a href="#" class="view_link" id="view_link" style="display: none;">View Radar</a>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div class="message" id="message"></div>
|
|
292
|
+
|
|
293
|
+
<script>
|
|
294
|
+
// Plugin API routes are at /plugins/<plugin-id>/, not at the webapp path
|
|
295
|
+
const API_BASE = '/plugins/mayara-server-signalk-playbackrecordings-plugin';
|
|
296
|
+
|
|
297
|
+
let selectedFile = null;
|
|
298
|
+
let loadedFile = null; // Track currently loaded/playing file
|
|
299
|
+
let statusInterval = null;
|
|
300
|
+
|
|
301
|
+
// Initialize
|
|
302
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
303
|
+
loadRecordings();
|
|
304
|
+
setupUpload();
|
|
305
|
+
setupControls();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Load recordings list
|
|
309
|
+
async function loadRecordings() {
|
|
310
|
+
try {
|
|
311
|
+
const res = await fetch(`${API_BASE}/recordings`);
|
|
312
|
+
const data = await res.json();
|
|
313
|
+
|
|
314
|
+
const list = document.getElementById('file_list');
|
|
315
|
+
|
|
316
|
+
if (!data.recordings || data.recordings.length === 0) {
|
|
317
|
+
list.innerHTML = '<div class="no_files">No recordings found. Upload a .mrr file to get started.</div>';
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
list.innerHTML = '';
|
|
322
|
+
data.recordings.forEach(file => {
|
|
323
|
+
const item = document.createElement('div');
|
|
324
|
+
item.className = 'file_item';
|
|
325
|
+
item.dataset.filename = file.filename;
|
|
326
|
+
|
|
327
|
+
const duration = formatDuration(file.durationMs || 0);
|
|
328
|
+
const size = formatSize(file.size || 0);
|
|
329
|
+
|
|
330
|
+
item.innerHTML = `
|
|
331
|
+
<div>
|
|
332
|
+
<div class="file_name">${file.filename}</div>
|
|
333
|
+
<div class="file_meta">${duration} • ${size} • ${file.frameCount || '?'} frames</div>
|
|
334
|
+
</div>
|
|
335
|
+
<div class="file_actions">
|
|
336
|
+
<button class="load" onclick="loadFile('${file.filename}'); event.stopPropagation();">Load</button>
|
|
337
|
+
<button class="delete" onclick="deleteFile('${file.filename}'); event.stopPropagation();">Delete</button>
|
|
338
|
+
</div>
|
|
339
|
+
`;
|
|
340
|
+
|
|
341
|
+
item.addEventListener('click', () => {
|
|
342
|
+
document.querySelectorAll('.file_item').forEach(i => i.classList.remove('selected'));
|
|
343
|
+
item.classList.add('selected');
|
|
344
|
+
selectedFile = file.filename;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
list.appendChild(item);
|
|
348
|
+
});
|
|
349
|
+
} catch (err) {
|
|
350
|
+
showMessage('Failed to load recordings: ' + err.message, 'error');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Setup file upload
|
|
355
|
+
function setupUpload() {
|
|
356
|
+
const zone = document.getElementById('upload_zone');
|
|
357
|
+
const input = document.getElementById('file_input');
|
|
358
|
+
|
|
359
|
+
zone.addEventListener('click', () => input.click());
|
|
360
|
+
|
|
361
|
+
zone.addEventListener('dragover', (e) => {
|
|
362
|
+
e.preventDefault();
|
|
363
|
+
zone.classList.add('dragover');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
zone.addEventListener('dragleave', () => {
|
|
367
|
+
zone.classList.remove('dragover');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
zone.addEventListener('drop', (e) => {
|
|
371
|
+
e.preventDefault();
|
|
372
|
+
zone.classList.remove('dragover');
|
|
373
|
+
if (e.dataTransfer.files.length > 0) {
|
|
374
|
+
uploadFile(e.dataTransfer.files[0]);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
input.addEventListener('change', () => {
|
|
379
|
+
if (input.files.length > 0) {
|
|
380
|
+
uploadFile(input.files[0]);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Upload file
|
|
386
|
+
async function uploadFile(file) {
|
|
387
|
+
try {
|
|
388
|
+
showMessage('Uploading...', 'success');
|
|
389
|
+
|
|
390
|
+
const res = await fetch(`${API_BASE}/recordings/upload`, {
|
|
391
|
+
method: 'POST',
|
|
392
|
+
headers: {
|
|
393
|
+
'Content-Disposition': `attachment; filename="${file.name}"`
|
|
394
|
+
},
|
|
395
|
+
body: file
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (!res.ok) {
|
|
399
|
+
throw new Error('Upload failed');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
showMessage('Upload complete!', 'success');
|
|
403
|
+
loadRecordings();
|
|
404
|
+
} catch (err) {
|
|
405
|
+
showMessage('Upload failed: ' + err.message, 'error');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Delete file
|
|
410
|
+
async function deleteFile(filename) {
|
|
411
|
+
if (!confirm(`Delete "${filename}"?`)) return;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
await fetch(`${API_BASE}/recordings/${encodeURIComponent(filename)}`, {
|
|
415
|
+
method: 'DELETE'
|
|
416
|
+
});
|
|
417
|
+
showMessage('Deleted', 'success');
|
|
418
|
+
loadRecordings();
|
|
419
|
+
} catch (err) {
|
|
420
|
+
showMessage('Delete failed: ' + err.message, 'error');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Load file for playback
|
|
425
|
+
async function loadFile(filename) {
|
|
426
|
+
try {
|
|
427
|
+
// Disable buttons during transition to prevent double-clicks
|
|
428
|
+
document.getElementById('btn_play').disabled = true;
|
|
429
|
+
document.getElementById('btn_pause').disabled = true;
|
|
430
|
+
document.getElementById('btn_stop').disabled = true;
|
|
431
|
+
|
|
432
|
+
// If a different file is currently loaded/playing, stop it first
|
|
433
|
+
if (loadedFile && loadedFile !== filename) {
|
|
434
|
+
stopStatusPolling();
|
|
435
|
+
try {
|
|
436
|
+
await fetch(`${API_BASE}/playback/stop`, { method: 'POST' });
|
|
437
|
+
} catch (err) {
|
|
438
|
+
console.log('Stop on file change (expected):', err.message);
|
|
439
|
+
}
|
|
440
|
+
loadedFile = null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const res = await fetch(`${API_BASE}/playback/load`, {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: { 'Content-Type': 'application/json' },
|
|
446
|
+
body: JSON.stringify({ filename })
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (!res.ok) {
|
|
450
|
+
const data = await res.json();
|
|
451
|
+
throw new Error(data.error || 'Load failed');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const data = await res.json();
|
|
455
|
+
loadedFile = filename;
|
|
456
|
+
|
|
457
|
+
document.getElementById('btn_play').disabled = false;
|
|
458
|
+
document.getElementById('btn_stop').disabled = false;
|
|
459
|
+
document.getElementById('status_info').style.display = 'block';
|
|
460
|
+
|
|
461
|
+
// Update view link
|
|
462
|
+
const viewLink = document.getElementById('view_link');
|
|
463
|
+
viewLink.href = `viewer.html?id=${encodeURIComponent(data.radarId)}`;
|
|
464
|
+
viewLink.style.display = 'inline-block';
|
|
465
|
+
|
|
466
|
+
showMessage(`Loaded: ${filename}`, 'success');
|
|
467
|
+
startStatusPolling();
|
|
468
|
+
} catch (err) {
|
|
469
|
+
showMessage('Load failed: ' + err.message, 'error');
|
|
470
|
+
// Re-enable play if we had a file loaded before
|
|
471
|
+
if (loadedFile) {
|
|
472
|
+
document.getElementById('btn_play').disabled = false;
|
|
473
|
+
document.getElementById('btn_stop').disabled = false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Setup playback controls
|
|
479
|
+
function setupControls() {
|
|
480
|
+
document.getElementById('btn_play').addEventListener('click', async () => {
|
|
481
|
+
await fetch(`${API_BASE}/playback/play`, { method: 'POST' });
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
document.getElementById('btn_pause').addEventListener('click', async () => {
|
|
485
|
+
await fetch(`${API_BASE}/playback/pause`, { method: 'POST' });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
document.getElementById('btn_stop').addEventListener('click', async () => {
|
|
489
|
+
await fetch(`${API_BASE}/playback/stop`, { method: 'POST' });
|
|
490
|
+
loadedFile = null;
|
|
491
|
+
document.getElementById('btn_play').disabled = true;
|
|
492
|
+
document.getElementById('btn_pause').disabled = true;
|
|
493
|
+
document.getElementById('btn_stop').disabled = true;
|
|
494
|
+
document.getElementById('status_info').style.display = 'none';
|
|
495
|
+
document.getElementById('view_link').style.display = 'none';
|
|
496
|
+
stopStatusPolling();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
document.getElementById('loop_checkbox').addEventListener('change', async (e) => {
|
|
500
|
+
await fetch(`${API_BASE}/playback/settings`, {
|
|
501
|
+
method: 'PUT',
|
|
502
|
+
headers: { 'Content-Type': 'application/json' },
|
|
503
|
+
body: JSON.stringify({ loopPlayback: e.target.checked })
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Status polling
|
|
509
|
+
function startStatusPolling() {
|
|
510
|
+
stopStatusPolling();
|
|
511
|
+
statusInterval = setInterval(updateStatus, 500);
|
|
512
|
+
updateStatus();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function stopStatusPolling() {
|
|
516
|
+
if (statusInterval) {
|
|
517
|
+
clearInterval(statusInterval);
|
|
518
|
+
statusInterval = null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function updateStatus() {
|
|
523
|
+
try {
|
|
524
|
+
const res = await fetch(`${API_BASE}/playback/status`);
|
|
525
|
+
const status = await res.json();
|
|
526
|
+
|
|
527
|
+
if (status.state === 'idle') {
|
|
528
|
+
document.getElementById('status_info').style.display = 'none';
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
document.getElementById('status_filename').textContent = status.filename || '-';
|
|
533
|
+
document.getElementById('status_state').textContent = status.state || '-';
|
|
534
|
+
document.getElementById('status_position').textContent =
|
|
535
|
+
`${formatDuration(status.positionMs || 0)} / ${formatDuration(status.durationMs || 0)}`;
|
|
536
|
+
document.getElementById('status_frame').textContent =
|
|
537
|
+
`${status.frame || 0} / ${status.frameCount || 0}`;
|
|
538
|
+
|
|
539
|
+
document.getElementById('btn_play').disabled = status.state === 'playing';
|
|
540
|
+
document.getElementById('btn_pause').disabled = status.state !== 'playing';
|
|
541
|
+
document.getElementById('loop_checkbox').checked = status.loopPlayback || false;
|
|
542
|
+
|
|
543
|
+
} catch (err) {
|
|
544
|
+
console.error('Status update failed:', err);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Utilities
|
|
549
|
+
function formatDuration(ms) {
|
|
550
|
+
if (!ms || ms <= 0) return '0:00';
|
|
551
|
+
const seconds = Math.floor(ms / 1000);
|
|
552
|
+
const minutes = Math.floor(seconds / 60);
|
|
553
|
+
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function formatSize(bytes) {
|
|
557
|
+
if (!bytes || bytes <= 0) return '0 KB';
|
|
558
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
559
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
560
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function showMessage(text, type) {
|
|
564
|
+
const el = document.getElementById('message');
|
|
565
|
+
el.textContent = text;
|
|
566
|
+
el.className = `message ${type}`;
|
|
567
|
+
el.style.display = 'block';
|
|
568
|
+
setTimeout(() => { el.style.display = 'none'; }, 3000);
|
|
569
|
+
}
|
|
570
|
+
</script>
|
|
571
|
+
</body>
|
|
572
|
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
syntax = "proto3";
|
|
2
|
+
option go_package = "../radar";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The data stream coming from a radar is a series of spokes.
|
|
6
|
+
* The number of spokes per revolution is different for each type of
|
|
7
|
+
* radar and can be found in the radar specification found at
|
|
8
|
+
* .../radars as 'spokes_per_revolution'. The maximum length of each
|
|
9
|
+
* spoke is also defined there, as well as the legend that provides
|
|
10
|
+
* a lookup table for each byte of data in the spoke.
|
|
11
|
+
*
|
|
12
|
+
* The angle and bearing fields below are in terms of spokes, so
|
|
13
|
+
* range from [0..spokes_per_revolution>.
|
|
14
|
+
*
|
|
15
|
+
* Angle is a mandatory field and tells you the rotation of the spoke
|
|
16
|
+
* relative to the front of the boat, going clockwise. 0 means directly
|
|
17
|
+
* ahead, spokes_per_revolution / 4 is to starboard, spokes_per_revolution / 2 is directly astern, etc.
|
|
18
|
+
*
|
|
19
|
+
* Bearing, if set, means that either the radar or the radar server has
|
|
20
|
+
* enriched the data with a true bearing, e.g. 0 is directly North,
|
|
21
|
+
* spokes_per_revolution / 4 is directly West, spokes_per_revolution / 2 is South, etc.
|
|
22
|
+
*
|
|
23
|
+
* Likewise, time and lat/lon indicate the best effort when the spoke
|
|
24
|
+
* was generated, and the lat/lon of the radar at the time of generation.
|
|
25
|
+
*
|
|
26
|
+
* Latitude and longitude are expressed in 10**-16 degrees, for compatibility
|
|
27
|
+
* with NMEA-2000 data.
|
|
28
|
+
*/
|
|
29
|
+
message RadarMessage {
|
|
30
|
+
uint32 radar = 1;
|
|
31
|
+
message Spoke {
|
|
32
|
+
uint32 angle = 1; // [0..spokes_per_revolution>, angle from bow
|
|
33
|
+
optional uint32 bearing = 2; // [0..spokes_per_revolution>, offset from True North
|
|
34
|
+
uint32 range = 3; // [meters], range in meters of the last pixel in data
|
|
35
|
+
optional uint64 time = 4; // [millis since UNIX epoch] Time when spoke was generated or received
|
|
36
|
+
optional int64 lat = 6; // [1e-16 degree] Location of radar at time of generation
|
|
37
|
+
optional int64 lon = 7; // [1e-16 degree] Location of radar at time of generation
|
|
38
|
+
bytes data = 5;
|
|
39
|
+
}
|
|
40
|
+
repeated Spoke spokes = 2;
|
|
41
|
+
}
|