@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
package/plugin/index.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MaYaRa Radar Playback SignalK Plugin
|
|
3
|
+
*
|
|
4
|
+
* Plays .mrr radar recordings through SignalK's Radar API.
|
|
5
|
+
* This is a developer tool for testing SignalK Radar API consumers
|
|
6
|
+
* with consistent recorded data.
|
|
7
|
+
*
|
|
8
|
+
* Does NOT require mayara-server - reads .mrr files directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs')
|
|
12
|
+
const path = require('path')
|
|
13
|
+
const { MrrReader } = require('./mrr-reader')
|
|
14
|
+
|
|
15
|
+
module.exports = function (app) {
|
|
16
|
+
let player = null
|
|
17
|
+
let recordingsDir = null
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create RadarProvider methods for SignalK Radar API
|
|
21
|
+
* Returns playback radar info when a recording is loaded
|
|
22
|
+
*/
|
|
23
|
+
function createRadarProvider() {
|
|
24
|
+
return {
|
|
25
|
+
async getRadars() {
|
|
26
|
+
if (player && player.radarId) {
|
|
27
|
+
return [player.radarId]
|
|
28
|
+
}
|
|
29
|
+
return []
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async getRadarInfo(radarId) {
|
|
33
|
+
if (!player || player.radarId !== radarId) return null
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
id: player.radarId,
|
|
37
|
+
name: `Playback: ${player.filename}`,
|
|
38
|
+
brand: 'Playback',
|
|
39
|
+
model: 'Recording',
|
|
40
|
+
status: player.playing ? 'transmit' : 'standby',
|
|
41
|
+
spokesPerRevolution: player.spokesPerRev || 2048,
|
|
42
|
+
maxSpokeLen: player.maxSpokeLen || 512,
|
|
43
|
+
range: player.range || 1852,
|
|
44
|
+
controls: {
|
|
45
|
+
power: player.playing ? 2 : 1,
|
|
46
|
+
range: player.range || 1852
|
|
47
|
+
},
|
|
48
|
+
isPlayback: true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async getCapabilities(radarId) {
|
|
53
|
+
if (!player || player.radarId !== radarId) return null
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
id: player.radarId,
|
|
57
|
+
make: 'Playback',
|
|
58
|
+
model: 'Recording',
|
|
59
|
+
characteristics: {
|
|
60
|
+
spokesPerRevolution: player.spokesPerRev || 2048,
|
|
61
|
+
maxSpokeLength: player.maxSpokeLen || 512,
|
|
62
|
+
pixelValues: player.pixelValues || 64
|
|
63
|
+
},
|
|
64
|
+
// Controls must be an array for buildControlsFromCapabilities()
|
|
65
|
+
controls: [
|
|
66
|
+
{
|
|
67
|
+
id: 'power',
|
|
68
|
+
type: 'enum',
|
|
69
|
+
values: ['off', 'standby', 'transmit'],
|
|
70
|
+
readOnly: true
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'range',
|
|
74
|
+
type: 'number',
|
|
75
|
+
min: 50,
|
|
76
|
+
max: 96000,
|
|
77
|
+
readOnly: true
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
isPlayback: true
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async getState(radarId) {
|
|
85
|
+
if (!player || player.radarId !== radarId) return null
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
status: player.playing ? 'transmit' : 'standby',
|
|
89
|
+
controls: {
|
|
90
|
+
power: player.playing ? 2 : 1,
|
|
91
|
+
range: player.range || 1852
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async getControl(radarId, controlId) {
|
|
97
|
+
if (!player || player.radarId !== radarId) return null
|
|
98
|
+
const state = await this.getState(radarId)
|
|
99
|
+
return state?.controls?.[controlId] ?? null
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// Control methods - playback is read-only
|
|
103
|
+
async setPower(radarId, state) {
|
|
104
|
+
app.debug(`setPower ignored for playback radar (read-only)`)
|
|
105
|
+
return false
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async setRange(radarId, range) {
|
|
109
|
+
app.debug(`setRange ignored for playback radar (read-only)`)
|
|
110
|
+
return false
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async setControl(radarId, controlId, value) {
|
|
114
|
+
app.debug(`setControl ignored for playback radar (read-only)`)
|
|
115
|
+
return false
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async setControls(radarId, controls) {
|
|
119
|
+
app.debug(`setControls ignored for playback radar (read-only)`)
|
|
120
|
+
return false
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// ARPA not supported for playback
|
|
124
|
+
async getTargets(radarId) {
|
|
125
|
+
return { targets: [] }
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async acquireTarget(radarId, bearing, distance) {
|
|
129
|
+
return { success: false, error: 'Not supported for playback' }
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async cancelTarget(radarId, targetId) {
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const plugin = {
|
|
139
|
+
id: 'mayara-server-signalk-playbackrecordings-plugin',
|
|
140
|
+
name: 'MaYaRa Radar Playback',
|
|
141
|
+
description: 'Play .mrr radar recordings through SignalK Radar API (Developer Tool)',
|
|
142
|
+
|
|
143
|
+
schema: () => ({
|
|
144
|
+
type: 'object',
|
|
145
|
+
title: 'MaYaRa Radar Playback Settings',
|
|
146
|
+
properties: {
|
|
147
|
+
recordingsDir: {
|
|
148
|
+
type: 'string',
|
|
149
|
+
title: 'Recordings Directory',
|
|
150
|
+
description: 'Directory containing .mrr files (leave empty for plugin data directory)',
|
|
151
|
+
default: ''
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
155
|
+
|
|
156
|
+
start: function (settings) {
|
|
157
|
+
app.debug('Starting mayara-playback plugin')
|
|
158
|
+
|
|
159
|
+
// Set recordings directory
|
|
160
|
+
recordingsDir = settings.recordingsDir || path.join(app.getDataDirPath(), 'recordings')
|
|
161
|
+
|
|
162
|
+
// Ensure directory exists
|
|
163
|
+
if (!fs.existsSync(recordingsDir)) {
|
|
164
|
+
fs.mkdirSync(recordingsDir, { recursive: true })
|
|
165
|
+
app.debug(`Created recordings directory: ${recordingsDir}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Register with SignalK Radar API
|
|
169
|
+
if (app.radarApi) {
|
|
170
|
+
try {
|
|
171
|
+
app.radarApi.register(plugin.id, {
|
|
172
|
+
name: plugin.name,
|
|
173
|
+
methods: createRadarProvider()
|
|
174
|
+
})
|
|
175
|
+
app.debug('Registered as radar provider with SignalK Radar API')
|
|
176
|
+
} catch (err) {
|
|
177
|
+
app.error(`Failed to register radar provider: ${err.message}`)
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
app.debug('SignalK Radar API not available - spoke streaming will not work')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
app.setPluginStatus('Ready - No recording loaded')
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
stop: function () {
|
|
187
|
+
app.debug('Stopping mayara-playback plugin')
|
|
188
|
+
|
|
189
|
+
// Unregister from radar API
|
|
190
|
+
if (app.radarApi) {
|
|
191
|
+
try {
|
|
192
|
+
app.radarApi.unRegister(plugin.id)
|
|
193
|
+
app.debug('Unregistered from radar API')
|
|
194
|
+
} catch (err) {
|
|
195
|
+
app.debug(`Error unregistering: ${err.message}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (player) {
|
|
200
|
+
player.stop()
|
|
201
|
+
player = null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
app.setPluginStatus('Stopped')
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
registerWithRouter: function (router) {
|
|
208
|
+
// List available recordings
|
|
209
|
+
router.get('/recordings', (req, res) => {
|
|
210
|
+
try {
|
|
211
|
+
const files = listRecordings()
|
|
212
|
+
res.json({ recordings: files })
|
|
213
|
+
} catch (err) {
|
|
214
|
+
res.status(500).json({ error: err.message })
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Upload a recording
|
|
219
|
+
router.post('/recordings/upload', async (req, res) => {
|
|
220
|
+
try {
|
|
221
|
+
// Handle multipart form data
|
|
222
|
+
const chunks = []
|
|
223
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
224
|
+
req.on('end', () => {
|
|
225
|
+
const body = Buffer.concat(chunks)
|
|
226
|
+
|
|
227
|
+
// Extract filename from Content-Disposition header or generate one
|
|
228
|
+
let filename = `upload_${Date.now()}.mrr`
|
|
229
|
+
const contentDisp = req.headers['content-disposition']
|
|
230
|
+
if (contentDisp) {
|
|
231
|
+
const match = contentDisp.match(/filename="?([^";\s]+)"?/)
|
|
232
|
+
if (match) filename = match[1]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Save file
|
|
236
|
+
const filePath = path.join(recordingsDir, filename)
|
|
237
|
+
fs.writeFileSync(filePath, body)
|
|
238
|
+
|
|
239
|
+
app.debug(`Uploaded recording: ${filename} (${body.length} bytes)`)
|
|
240
|
+
res.json({ filename, size: body.length })
|
|
241
|
+
})
|
|
242
|
+
} catch (err) {
|
|
243
|
+
res.status(500).json({ error: err.message })
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// Delete a recording
|
|
248
|
+
router.delete('/recordings/:filename', (req, res) => {
|
|
249
|
+
try {
|
|
250
|
+
const filePath = path.join(recordingsDir, req.params.filename)
|
|
251
|
+
if (!fs.existsSync(filePath)) {
|
|
252
|
+
return res.status(404).json({ error: 'Recording not found' })
|
|
253
|
+
}
|
|
254
|
+
fs.unlinkSync(filePath)
|
|
255
|
+
res.json({ ok: true })
|
|
256
|
+
} catch (err) {
|
|
257
|
+
res.status(500).json({ error: err.message })
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// Load a recording for playback
|
|
262
|
+
router.post('/playback/load', async (req, res) => {
|
|
263
|
+
try {
|
|
264
|
+
const { filename } = req.body
|
|
265
|
+
if (!filename) {
|
|
266
|
+
return res.status(400).json({ error: 'filename required' })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const filePath = path.join(recordingsDir, filename)
|
|
270
|
+
if (!fs.existsSync(filePath)) {
|
|
271
|
+
return res.status(404).json({ error: 'Recording not found' })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Stop existing player and wait for cleanup
|
|
275
|
+
if (player) {
|
|
276
|
+
app.debug(`Stopping existing playback before loading new: ${player.filename}`)
|
|
277
|
+
player.stop()
|
|
278
|
+
player = null
|
|
279
|
+
// Small delay to allow old playback to fully stop
|
|
280
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Create new player
|
|
284
|
+
player = new MrrPlayer(app, filePath)
|
|
285
|
+
await player.load()
|
|
286
|
+
|
|
287
|
+
app.setPluginStatus(`Loaded: ${filename}`)
|
|
288
|
+
res.json({
|
|
289
|
+
radarId: player.radarId,
|
|
290
|
+
filename: filename,
|
|
291
|
+
durationMs: player.durationMs,
|
|
292
|
+
frameCount: player.frameCount
|
|
293
|
+
})
|
|
294
|
+
} catch (err) {
|
|
295
|
+
app.error(`Load failed: ${err.message}`)
|
|
296
|
+
res.status(500).json({ error: err.message })
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Play
|
|
301
|
+
router.post('/playback/play', (req, res) => {
|
|
302
|
+
if (!player) {
|
|
303
|
+
return res.status(400).json({ error: 'No recording loaded' })
|
|
304
|
+
}
|
|
305
|
+
player.play()
|
|
306
|
+
app.setPluginStatus(`Playing: ${player.filename}`)
|
|
307
|
+
res.json({ ok: true })
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// Pause
|
|
311
|
+
router.post('/playback/pause', (req, res) => {
|
|
312
|
+
if (!player) {
|
|
313
|
+
return res.status(400).json({ error: 'No recording loaded' })
|
|
314
|
+
}
|
|
315
|
+
player.pause()
|
|
316
|
+
app.setPluginStatus(`Paused: ${player.filename}`)
|
|
317
|
+
res.json({ ok: true })
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// Stop
|
|
321
|
+
router.post('/playback/stop', (req, res) => {
|
|
322
|
+
if (!player) {
|
|
323
|
+
return res.status(400).json({ error: 'No recording loaded' })
|
|
324
|
+
}
|
|
325
|
+
player.stop()
|
|
326
|
+
player = null
|
|
327
|
+
app.setPluginStatus('Ready - No recording loaded')
|
|
328
|
+
res.json({ ok: true })
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// Get status
|
|
332
|
+
router.get('/playback/status', (req, res) => {
|
|
333
|
+
if (!player) {
|
|
334
|
+
return res.json({ state: 'idle', loopPlayback: true })
|
|
335
|
+
}
|
|
336
|
+
res.json(player.getStatus())
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// Settings (loop)
|
|
340
|
+
router.put('/playback/settings', (req, res) => {
|
|
341
|
+
if (!player) {
|
|
342
|
+
return res.status(400).json({ error: 'No recording loaded' })
|
|
343
|
+
}
|
|
344
|
+
const { loopPlayback } = req.body
|
|
345
|
+
if (typeof loopPlayback === 'boolean') {
|
|
346
|
+
player.loop = loopPlayback
|
|
347
|
+
}
|
|
348
|
+
res.json({ ok: true })
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* List recordings in the recordings directory
|
|
355
|
+
*/
|
|
356
|
+
function listRecordings() {
|
|
357
|
+
if (!fs.existsSync(recordingsDir)) {
|
|
358
|
+
return []
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const files = fs.readdirSync(recordingsDir)
|
|
362
|
+
.filter(f => f.endsWith('.mrr') || f.endsWith('.mrr.gz'))
|
|
363
|
+
.map(filename => {
|
|
364
|
+
const filePath = path.join(recordingsDir, filename)
|
|
365
|
+
const stats = fs.statSync(filePath)
|
|
366
|
+
|
|
367
|
+
// Try to read metadata
|
|
368
|
+
let metadata = {}
|
|
369
|
+
try {
|
|
370
|
+
const reader = new MrrReader(filePath)
|
|
371
|
+
// Sync load for listing (could optimize later)
|
|
372
|
+
const data = fs.readFileSync(filePath)
|
|
373
|
+
const zlib = require('zlib')
|
|
374
|
+
const buf = filename.endsWith('.gz') ? zlib.gunzipSync(data) : data
|
|
375
|
+
|
|
376
|
+
// Quick parse just header and footer
|
|
377
|
+
const { MrrHeader, MrrFooter, HEADER_SIZE, FOOTER_SIZE } = require('./mrr-reader')
|
|
378
|
+
const header = MrrHeader.fromBuffer(buf)
|
|
379
|
+
const footer = MrrFooter.fromBuffer(buf.subarray(buf.length - FOOTER_SIZE))
|
|
380
|
+
|
|
381
|
+
metadata = {
|
|
382
|
+
durationMs: footer.durationMs,
|
|
383
|
+
frameCount: footer.frameCount,
|
|
384
|
+
spokesPerRev: header.spokesPerRev,
|
|
385
|
+
radarBrand: header.radarBrand
|
|
386
|
+
}
|
|
387
|
+
} catch (e) {
|
|
388
|
+
app.debug(`Could not read metadata for ${filename}: ${e.message}`)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
filename,
|
|
393
|
+
size: stats.size,
|
|
394
|
+
modifiedMs: stats.mtimeMs,
|
|
395
|
+
...metadata
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Sort by modification time, newest first
|
|
400
|
+
files.sort((a, b) => b.modifiedMs - a.modifiedMs)
|
|
401
|
+
|
|
402
|
+
return files
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return plugin
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* MRR Playback Player
|
|
410
|
+
* Reads frames from .mrr file and emits them through SignalK Radar API
|
|
411
|
+
*/
|
|
412
|
+
class MrrPlayer {
|
|
413
|
+
constructor(app, filePath) {
|
|
414
|
+
this.app = app
|
|
415
|
+
this.filePath = filePath
|
|
416
|
+
this.filename = path.basename(filePath)
|
|
417
|
+
this.reader = new MrrReader(filePath)
|
|
418
|
+
this.radarId = null
|
|
419
|
+
this.durationMs = 0
|
|
420
|
+
this.frameCount = 0
|
|
421
|
+
this.playing = false
|
|
422
|
+
this.loop = true // Default to looping
|
|
423
|
+
this.currentFrame = 0
|
|
424
|
+
this.positionMs = 0
|
|
425
|
+
this.playbackTimer = null
|
|
426
|
+
this.frames = [] // Pre-loaded frames for proper timing
|
|
427
|
+
// Metadata for RadarProvider
|
|
428
|
+
this.spokesPerRev = 2048
|
|
429
|
+
this.maxSpokeLen = 512
|
|
430
|
+
this.pixelValues = 64
|
|
431
|
+
this.range = 1852
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async load() {
|
|
435
|
+
await this.reader.load()
|
|
436
|
+
|
|
437
|
+
const meta = this.reader.getMetadata()
|
|
438
|
+
this.durationMs = meta.durationMs
|
|
439
|
+
this.frameCount = meta.frameCount
|
|
440
|
+
|
|
441
|
+
// Store metadata for RadarProvider to access
|
|
442
|
+
this.spokesPerRev = meta.spokesPerRev || 2048
|
|
443
|
+
this.maxSpokeLen = meta.maxSpokeLen || 512
|
|
444
|
+
this.pixelValues = meta.pixelValues || 64
|
|
445
|
+
this.range = meta.initialState?.range || 1852
|
|
446
|
+
|
|
447
|
+
// Pre-load all frames for proper timing (avoids hacky read-ahead)
|
|
448
|
+
this.frames = []
|
|
449
|
+
for (const frame of this.reader.frames()) {
|
|
450
|
+
this.frames.push(frame)
|
|
451
|
+
}
|
|
452
|
+
this.app.debug(`Pre-loaded ${this.frames.length} frames`)
|
|
453
|
+
|
|
454
|
+
// Generate radar ID from filename
|
|
455
|
+
const baseName = this.filename.replace(/\.mrr(\.gz)?$/, '')
|
|
456
|
+
this.radarId = `playback-${baseName}`
|
|
457
|
+
|
|
458
|
+
this.app.debug(`Loaded ${this.filename}: ${this.frameCount} frames, ${this.durationMs}ms, ${this.spokesPerRev} spokes/rev`)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
play() {
|
|
462
|
+
if (this.playing) return
|
|
463
|
+
|
|
464
|
+
this.playing = true
|
|
465
|
+
this.scheduleNextFrame()
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
pause() {
|
|
469
|
+
this.playing = false
|
|
470
|
+
if (this.playbackTimer) {
|
|
471
|
+
clearTimeout(this.playbackTimer)
|
|
472
|
+
this.playbackTimer = null
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
stop() {
|
|
477
|
+
this.pause()
|
|
478
|
+
|
|
479
|
+
// Unregister radar
|
|
480
|
+
if (this.app.radarApi && this.radarId) {
|
|
481
|
+
try {
|
|
482
|
+
// Unregister from SignalK
|
|
483
|
+
this.app.debug(`Unregistering radar: ${this.radarId}`)
|
|
484
|
+
} catch (err) {
|
|
485
|
+
this.app.debug(`Error unregistering: ${err.message}`)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Reset position
|
|
490
|
+
this.currentFrame = 0
|
|
491
|
+
this.positionMs = 0
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
getStatus() {
|
|
495
|
+
return {
|
|
496
|
+
state: this.playing ? 'playing' : (this.currentFrame > 0 ? 'paused' : 'loaded'),
|
|
497
|
+
radarId: this.radarId,
|
|
498
|
+
filename: this.filename,
|
|
499
|
+
positionMs: this.positionMs,
|
|
500
|
+
durationMs: this.durationMs,
|
|
501
|
+
frame: this.currentFrame,
|
|
502
|
+
frameCount: this.frameCount,
|
|
503
|
+
loopPlayback: this.loop
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
scheduleNextFrame() {
|
|
508
|
+
if (!this.playing) return
|
|
509
|
+
|
|
510
|
+
// Check if we've reached the end
|
|
511
|
+
if (this.currentFrame >= this.frames.length) {
|
|
512
|
+
// End of recording
|
|
513
|
+
if (this.loop) {
|
|
514
|
+
this.currentFrame = 0
|
|
515
|
+
this.positionMs = 0
|
|
516
|
+
this.scheduleNextFrame()
|
|
517
|
+
} else {
|
|
518
|
+
this.playing = false
|
|
519
|
+
this.app.setPluginStatus(`Finished: ${this.filename}`)
|
|
520
|
+
}
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const frame = this.frames[this.currentFrame]
|
|
525
|
+
|
|
526
|
+
// Emit frame through SignalK binary stream
|
|
527
|
+
// Stream ID must be "radars/{radarId}" to match WebSocket endpoint
|
|
528
|
+
if (this.app.binaryStreamManager) {
|
|
529
|
+
try {
|
|
530
|
+
const streamId = `radars/${this.radarId}`
|
|
531
|
+
this.app.binaryStreamManager.emitData(streamId, frame.data)
|
|
532
|
+
} catch (err) {
|
|
533
|
+
this.app.debug(`Error emitting frame: ${err.message}`)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
this.positionMs = frame.timestampMs
|
|
538
|
+
this.currentFrame++
|
|
539
|
+
|
|
540
|
+
// Schedule next frame based on timestamp delta
|
|
541
|
+
if (this.currentFrame < this.frames.length) {
|
|
542
|
+
const nextFrame = this.frames[this.currentFrame]
|
|
543
|
+
const deltaMs = nextFrame.timestampMs - frame.timestampMs
|
|
544
|
+
this.playbackTimer = setTimeout(() => this.scheduleNextFrame(), Math.max(1, deltaMs))
|
|
545
|
+
} else if (this.loop) {
|
|
546
|
+
// Last frame, schedule loop restart
|
|
547
|
+
this.playbackTimer = setTimeout(() => {
|
|
548
|
+
this.currentFrame = 0
|
|
549
|
+
this.positionMs = 0
|
|
550
|
+
this.scheduleNextFrame()
|
|
551
|
+
}, 100)
|
|
552
|
+
} else {
|
|
553
|
+
this.playing = false
|
|
554
|
+
this.app.setPluginStatus(`Finished: ${this.filename}`)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|