@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.
@@ -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
+ }