@marineyachtradar/signalk-playback-plugin 0.1.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>