@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.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/build.js +248 -0
- package/package.json +46 -0
- package/plugin/index.js +557 -0
- package/plugin/mrr-reader.js +315 -0
- package/plugin/public/assets/MaYaRa_RED.png +0 -0
- package/plugin/public/index.html +10 -0
- package/plugin/public/playback.html +572 -0
|
@@ -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>
|