smcgateway-sv 0.3.3 → 0.4.2
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 +1 -1
- package/dist/AudioTimeLinePlayer.svelte +329 -0
- package/dist/AudioTimeLinePlayer.svelte.d.ts +3 -0
- package/dist/InputDate.svelte +8 -5
- package/dist/InputDate.svelte.d.ts +2 -2
- package/dist/InputDatetime.svelte +25 -9
- package/dist/InputDatetime.svelte.d.ts +3 -2
- package/dist/auth.svelte.d.ts +4 -1
- package/dist/auth.svelte.js +9 -8
- package/dist/colours.d.ts +2 -0
- package/dist/colours.js +61 -0
- package/dist/geofence.svelte.d.ts +7 -8
- package/dist/geofence.svelte.js +71 -14
- package/dist/ghistory.svelte.js +28 -40
- package/dist/gtime.svelte.d.ts +2 -1
- package/dist/gtime.svelte.js +14 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/recording.svelte.d.ts +2 -0
- package/dist/recording.svelte.js +28 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { recording } from './recording.svelte.js';
|
|
4
|
+
import InputDatetime from './InputDatetime.svelte';
|
|
5
|
+
import { colors } from './colours.js';
|
|
6
|
+
|
|
7
|
+
type Segment = {
|
|
8
|
+
url: string;
|
|
9
|
+
start: Date;
|
|
10
|
+
end: Date;
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
startPercent: number;
|
|
14
|
+
durPercent: number;
|
|
15
|
+
color: string;
|
|
16
|
+
style: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Auto assign colours to devices
|
|
20
|
+
let nextColorIndex = 0;
|
|
21
|
+
let deviceColorMap: { [k: string]: string } = {};
|
|
22
|
+
|
|
23
|
+
const WINDOW_MS = 10 * 60 * 1000;
|
|
24
|
+
const STEP_MS = 10_000;
|
|
25
|
+
const TICK_MS = 200;
|
|
26
|
+
|
|
27
|
+
let windowStart = $state(new Date(Date.now() - WINDOW_MS));
|
|
28
|
+
let windowEnd = $derived(new Date(windowStart.getTime() + WINDOW_MS));
|
|
29
|
+
let segments: Segment[] = $state([]);
|
|
30
|
+
let audioEl: HTMLAudioElement;
|
|
31
|
+
let activeSegment: Segment | undefined = $state(undefined);
|
|
32
|
+
let playing = $state(false);
|
|
33
|
+
// svelte-ignore state_referenced_locally
|
|
34
|
+
let cursor = $state(windowStart.getTime());
|
|
35
|
+
let cursorStyle = $derived(
|
|
36
|
+
`left: ${((cursor - windowStart.getTime()) / (windowEnd.getTime() - windowStart.getTime())) * 100}%`
|
|
37
|
+
);
|
|
38
|
+
let playbackStartRealTime = 0;
|
|
39
|
+
let tickTimer = 0;
|
|
40
|
+
let isLoading = $state(false);
|
|
41
|
+
let error = $state('');
|
|
42
|
+
|
|
43
|
+
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
|
|
44
|
+
|
|
45
|
+
function findSegmentIndex(tMs: number) {
|
|
46
|
+
return segments.findIndex((s) => tMs >= s.start.getTime() && tMs < s.end.getTime());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadSegments() {
|
|
50
|
+
isLoading = true;
|
|
51
|
+
error = '';
|
|
52
|
+
try {
|
|
53
|
+
const ret = await recording.getRecordings(windowStart, windowEnd);
|
|
54
|
+
// console.log('getRecordings()', ret);
|
|
55
|
+
segments = ret.map((r) => {
|
|
56
|
+
let color = deviceColorMap[r.from];
|
|
57
|
+
if (!color) {
|
|
58
|
+
color = colors[nextColorIndex];
|
|
59
|
+
deviceColorMap[r.from] = color;
|
|
60
|
+
nextColorIndex++;
|
|
61
|
+
}
|
|
62
|
+
const startPercent = ((r.start.getTime() - windowStart.getTime()) / WINDOW_MS) * 100;
|
|
63
|
+
const durPercent = ((r.dur * 1000) / WINDOW_MS) * 100;
|
|
64
|
+
return {
|
|
65
|
+
url: `/api/recordings/${r.file}`,
|
|
66
|
+
start: r.start,
|
|
67
|
+
end: new Date(r.start.getTime() + r.dur * 1000),
|
|
68
|
+
from: r.from,
|
|
69
|
+
to: r.to,
|
|
70
|
+
startPercent,
|
|
71
|
+
durPercent,
|
|
72
|
+
color,
|
|
73
|
+
style: `left:${startPercent}%; width:${durPercent}%; background-color: ${color}`
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
} catch (e) {
|
|
77
|
+
error = 'Failed to fetch audio segments';
|
|
78
|
+
} finally {
|
|
79
|
+
isLoading = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function ensureAudioForCursor() {
|
|
84
|
+
const segIdx = findSegmentIndex(cursor);
|
|
85
|
+
if (segIdx === -1) {
|
|
86
|
+
// console.log('ensureAudioForCursor: in a gap', segIdx);
|
|
87
|
+
activeSegment = undefined;
|
|
88
|
+
// in a gap → ensure audio is paused
|
|
89
|
+
if (!audioEl.paused) audioEl.pause();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const seg = segments[segIdx];
|
|
94
|
+
const offsetSec = (cursor - seg.start.getTime()) / 1000;
|
|
95
|
+
|
|
96
|
+
if (!audioEl.src.endsWith(seg.url)) {
|
|
97
|
+
// console.log('LOAD', seg.url, audioEl.src);
|
|
98
|
+
activeSegment = seg;
|
|
99
|
+
audioEl.src = seg.url;
|
|
100
|
+
await audioEl.play().catch(console.error);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (Math.abs(audioEl.currentTime - offsetSec) > 0.5) {
|
|
104
|
+
audioEl.currentTime = offsetSec;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (audioEl.paused && playing) {
|
|
108
|
+
await audioEl.play().catch(console.error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function play() {
|
|
113
|
+
playbackStartRealTime = Date.now() - (cursor - windowStart.getTime());
|
|
114
|
+
playing = true;
|
|
115
|
+
startTick();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pause() {
|
|
119
|
+
playing = false;
|
|
120
|
+
stopTick();
|
|
121
|
+
audioEl?.pause();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function togglePlay() {
|
|
125
|
+
playing ? pause() : play();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function seekTo(ms: number) {
|
|
129
|
+
cursor = clamp(ms, windowStart.getTime(), windowEnd.getTime());
|
|
130
|
+
playbackStartRealTime = Date.now() - (cursor - windowStart.getTime());
|
|
131
|
+
ensureAudioForCursor();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function rewind(ms = STEP_MS) {
|
|
135
|
+
seekTo(cursor - ms);
|
|
136
|
+
}
|
|
137
|
+
function ffwd(ms = STEP_MS) {
|
|
138
|
+
seekTo(cursor + ms);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function startTick() {
|
|
142
|
+
stopTick();
|
|
143
|
+
tickTimer = setInterval(async () => {
|
|
144
|
+
if (!playing) return;
|
|
145
|
+
cursor = windowStart.getTime() + (Date.now() - playbackStartRealTime);
|
|
146
|
+
if (cursor >= windowEnd.getTime()) {
|
|
147
|
+
pause();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await ensureAudioForCursor();
|
|
151
|
+
}, TICK_MS);
|
|
152
|
+
}
|
|
153
|
+
function stopTick() {
|
|
154
|
+
if (tickTimer) clearInterval(tickTimer);
|
|
155
|
+
tickTimer = 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function onScrub(e: MouseEvent) {
|
|
159
|
+
if (e && e.currentTarget) {
|
|
160
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
161
|
+
const pct = clamp((e.clientX - rect.left) / rect.width, 0, 1);
|
|
162
|
+
seekTo(windowStart.getTime() + pct * (windowEnd.getTime() - windowStart.getTime()));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function onDateChange() {
|
|
167
|
+
// windowStart = fromLocalInputValue(dateTimeLocal);
|
|
168
|
+
windowEnd = new Date(windowStart.getTime() + WINDOW_MS);
|
|
169
|
+
cursor = windowStart.getTime();
|
|
170
|
+
await loadSegments();
|
|
171
|
+
ensureAudioForCursor();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function shiftWindow(delta: number) {
|
|
175
|
+
windowStart = new Date(windowStart.getTime() + delta);
|
|
176
|
+
windowEnd = new Date(windowStart.getTime() + WINDOW_MS);
|
|
177
|
+
// dateTimeLocal = toLocalInputValue(windowStart);
|
|
178
|
+
cursor = windowStart.getTime();
|
|
179
|
+
loadSegments();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
onMount(() => {
|
|
183
|
+
loadSegments();
|
|
184
|
+
});
|
|
185
|
+
onDestroy(() => stopTick());
|
|
186
|
+
</script>
|
|
187
|
+
|
|
188
|
+
<div class="player">
|
|
189
|
+
{@render selector(shiftWindow)}
|
|
190
|
+
{@render timeline()}
|
|
191
|
+
{@render callInfo(activeSegment)}
|
|
192
|
+
{@render controls(rewind, ffwd)}
|
|
193
|
+
|
|
194
|
+
{#snippet selector(shiftWindow: (delta: number) => void)}
|
|
195
|
+
<div class="d-flex justify-content-start">
|
|
196
|
+
<div>
|
|
197
|
+
<div class="input-group">
|
|
198
|
+
<InputDatetime
|
|
199
|
+
class="form-control text-center"
|
|
200
|
+
bind:value={windowStart}
|
|
201
|
+
onchange={onDateChange}
|
|
202
|
+
></InputDatetime>
|
|
203
|
+
<button class="btn btn-outline-secondary" onclick={() => shiftWindow(-10 * 60000)}
|
|
204
|
+
><< 10m</button
|
|
205
|
+
>
|
|
206
|
+
<button class="btn btn-outline-secondary" onclick={() => shiftWindow(-5 * 60000)}
|
|
207
|
+
><< 5m</button
|
|
208
|
+
>
|
|
209
|
+
<button class="btn btn-outline-secondary" onclick={() => shiftWindow(5 * 60000)}
|
|
210
|
+
>5m >></button
|
|
211
|
+
>
|
|
212
|
+
<button class="btn btn-outline-secondary" onclick={() => shiftWindow(10 * 60000)}
|
|
213
|
+
>10m >></button
|
|
214
|
+
>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
{/snippet}
|
|
219
|
+
|
|
220
|
+
{#snippet controls(rewind: (delta: number) => void, ffwd: (delta: number) => void)}
|
|
221
|
+
<div class="d-flex justify-content-center mt-1">
|
|
222
|
+
<div>
|
|
223
|
+
<div class="input-group">
|
|
224
|
+
<button class="btn btn-outline-secondary" onclick={() => rewind(60000)}
|
|
225
|
+
><< 60s</button
|
|
226
|
+
>
|
|
227
|
+
<button class="btn btn-outline-secondary" onclick={() => rewind(10000)}
|
|
228
|
+
><< 10s</button
|
|
229
|
+
>
|
|
230
|
+
<button class="btn btn-outline-secondary" onclick={() => rewind(5000)}><< 5s</button
|
|
231
|
+
>
|
|
232
|
+
<button class="btn btn-outline-secondary" onclick={togglePlay}
|
|
233
|
+
>{playing ? '⏸ Pause' : '▶️ Play'}</button
|
|
234
|
+
>
|
|
235
|
+
<button class="btn btn-outline-secondary" onclick={() => ffwd(5000)}>>> 5s</button>
|
|
236
|
+
<button class="btn btn-outline-secondary" onclick={() => ffwd(10000)}>>> 10s</button
|
|
237
|
+
>
|
|
238
|
+
<button class="btn btn-outline-secondary" onclick={() => ffwd(60000)}>>> 60s</button
|
|
239
|
+
>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
{/snippet}
|
|
244
|
+
|
|
245
|
+
{#snippet timeline()}
|
|
246
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
247
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
248
|
+
<div class="timeline mt-1">
|
|
249
|
+
<div class="bar" onclick={onScrub}>
|
|
250
|
+
{@render calls(segments)}
|
|
251
|
+
<div class="cursor" style={cursorStyle}></div>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="d-flex justify-content-between">
|
|
254
|
+
<div class="windowStartLabel">
|
|
255
|
+
{windowStart.toLocaleTimeString()}
|
|
256
|
+
</div>
|
|
257
|
+
<div>{new Date(cursor).toLocaleString()}</div>
|
|
258
|
+
<div class="windowEndLabel">
|
|
259
|
+
{windowEnd.toLocaleTimeString()}
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
{/snippet}
|
|
264
|
+
|
|
265
|
+
{#snippet callInfo(call: Segment | undefined)}
|
|
266
|
+
<div class="callinfo text-center mt-1">
|
|
267
|
+
{#if call}
|
|
268
|
+
Radio {call.from} [{call.to}]
|
|
269
|
+
{:else}
|
|
270
|
+
Idle
|
|
271
|
+
{/if}
|
|
272
|
+
</div>
|
|
273
|
+
{/snippet}
|
|
274
|
+
|
|
275
|
+
{#snippet calls(calls: Segment[])}
|
|
276
|
+
{#each calls as call}
|
|
277
|
+
<div class="call" title={call.from} style={call.style}></div>
|
|
278
|
+
{/each}
|
|
279
|
+
{/snippet}
|
|
280
|
+
|
|
281
|
+
<audio bind:this={audioEl} crossorigin="anonymous" preload="auto"></audio>
|
|
282
|
+
|
|
283
|
+
{#if isLoading}<p>Loading audio…</p>{/if}
|
|
284
|
+
{#if error}<p style="color:red;">{error}</p>{/if}
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<!-- windowStart: {windowStart.toISOString()}<br />
|
|
288
|
+
windowEnd: {windowEnd.toISOString()}<br />
|
|
289
|
+
cursor: {new Date(cursor).toISOString()}<br /> -->
|
|
290
|
+
|
|
291
|
+
<!-- <pre>Segments:
|
|
292
|
+
{JSON.stringify(segments, null, 2)}
|
|
293
|
+
</pre> -->
|
|
294
|
+
|
|
295
|
+
<style>
|
|
296
|
+
.player {
|
|
297
|
+
padding: 1rem;
|
|
298
|
+
border: 1px solid black;
|
|
299
|
+
border-radius: 8px;
|
|
300
|
+
}
|
|
301
|
+
.bar {
|
|
302
|
+
height: 2em;
|
|
303
|
+
background: var(--bs-bosy-color-rgb);
|
|
304
|
+
border-top: 1px solid black;
|
|
305
|
+
border-bottom: 1px solid black;
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
position: relative;
|
|
308
|
+
overflow: hidden;
|
|
309
|
+
}
|
|
310
|
+
.timeline {
|
|
311
|
+
border-left: 1px solid black;
|
|
312
|
+
border-right: 1px solid black;
|
|
313
|
+
}
|
|
314
|
+
.cursor {
|
|
315
|
+
position: absolute;
|
|
316
|
+
top: 0;
|
|
317
|
+
bottom: 0;
|
|
318
|
+
border-radius: 0;
|
|
319
|
+
border-right: 3px red solid;
|
|
320
|
+
}
|
|
321
|
+
.call {
|
|
322
|
+
position: absolute;
|
|
323
|
+
top: 0;
|
|
324
|
+
bottom: 0;
|
|
325
|
+
left: 0;
|
|
326
|
+
min-width: 2px;
|
|
327
|
+
border-radius: 0;
|
|
328
|
+
}
|
|
329
|
+
</style>
|
package/dist/InputDate.svelte
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
value = $bindable(),
|
|
6
6
|
class: className = '',
|
|
7
7
|
disabled = false,
|
|
8
|
-
nodate =
|
|
8
|
+
nodate = "",
|
|
9
9
|
min = new Date(2000, 1, 1),
|
|
10
10
|
max = new Date()
|
|
11
11
|
}: {
|
|
12
|
-
value: Date;
|
|
12
|
+
value: Date | "";
|
|
13
13
|
class?: string;
|
|
14
14
|
disabled?: boolean;
|
|
15
|
-
nodate?: Date;
|
|
15
|
+
nodate?: Date | "";
|
|
16
16
|
min?: Date;
|
|
17
17
|
max?: Date;
|
|
18
18
|
} = $props();
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
onMount(() => {
|
|
27
27
|
value = fromInputFormat(toInputFormat(value));
|
|
28
|
-
|
|
28
|
+
if (nodate instanceof Date) {
|
|
29
|
+
nodate.setHours(0, -nodate.getTimezoneOffset(), 0, 0);
|
|
30
|
+
}
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
// Sync internal when external date changes
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
}
|
|
37
39
|
});
|
|
38
40
|
|
|
39
|
-
function toInputFormat(date: Date): string {
|
|
41
|
+
function toInputFormat(date: Date | ""): string {
|
|
40
42
|
if (date instanceof Date && !isNaN(date.getTime())) {
|
|
41
43
|
let d = new Date(date);
|
|
42
44
|
// Remove timezone
|
|
@@ -58,6 +60,7 @@
|
|
|
58
60
|
value = fromInputFormat(target.value);
|
|
59
61
|
} else {
|
|
60
62
|
value = nodate
|
|
63
|
+
internal = ""
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
</script>
|
|
@@ -5,32 +5,44 @@
|
|
|
5
5
|
value = $bindable(),
|
|
6
6
|
class: className = '',
|
|
7
7
|
disabled = false,
|
|
8
|
-
nodate =
|
|
8
|
+
nodate = '',
|
|
9
9
|
min = new Date(2000, 1, 1),
|
|
10
|
-
max = new Date()
|
|
10
|
+
max = new Date(),
|
|
11
|
+
onchange = (e:Event) => {}
|
|
11
12
|
}: {
|
|
12
|
-
value: Date;
|
|
13
|
+
value: Date | '';
|
|
13
14
|
class?: string;
|
|
14
15
|
disabled?: boolean;
|
|
15
|
-
nodate?: Date;
|
|
16
|
+
nodate?: Date | '';
|
|
16
17
|
min?: Date;
|
|
17
18
|
max?: Date;
|
|
19
|
+
onchange?: (e:Event) => void;
|
|
18
20
|
} = $props();
|
|
19
21
|
|
|
20
22
|
let minDate = $derived(toInputFormat(min));
|
|
21
23
|
let maxDate = $derived(toInputFormat(max));
|
|
22
24
|
|
|
23
25
|
// internal is a string so as to work with the "input type=datetime-local"
|
|
24
|
-
|
|
26
|
+
let internal: string | undefined = $state('');
|
|
25
27
|
|
|
26
28
|
onMount(() => {
|
|
27
29
|
value = fromInputFormat(toInputFormat(value));
|
|
28
|
-
|
|
30
|
+
if (nodate instanceof Date) {
|
|
31
|
+
nodate.setHours(0, -nodate.getTimezoneOffset(), 0, 0);
|
|
32
|
+
}
|
|
29
33
|
});
|
|
30
34
|
|
|
31
|
-
let internal = $derived(toInputFormat(value))
|
|
35
|
+
// let internal = $derived(toInputFormat(value));
|
|
36
|
+
|
|
37
|
+
// Sync internal when external date changes
|
|
38
|
+
$effect(() => {
|
|
39
|
+
let v = toInputFormat(value);
|
|
40
|
+
if (v) {
|
|
41
|
+
internal = v;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
32
44
|
|
|
33
|
-
function toInputFormat(date: Date): string {
|
|
45
|
+
function toInputFormat(date: Date | ''): string {
|
|
34
46
|
if (date instanceof Date && !isNaN(date.getTime())) {
|
|
35
47
|
// Remove timezone to make time local
|
|
36
48
|
let d = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
|
|
@@ -46,11 +58,15 @@
|
|
|
46
58
|
|
|
47
59
|
function handleInput(event: Event) {
|
|
48
60
|
const target = event.target as HTMLInputElement;
|
|
61
|
+
console.log('handleInput', `[${target.value}]`);
|
|
49
62
|
if (target.value) {
|
|
50
63
|
value = fromInputFormat(target.value);
|
|
51
64
|
} else {
|
|
52
|
-
|
|
65
|
+
console.log('handleInput', `nodate`);
|
|
66
|
+
value = nodate;
|
|
67
|
+
internal = undefined;
|
|
53
68
|
}
|
|
69
|
+
onchange(event);
|
|
54
70
|
}
|
|
55
71
|
</script>
|
|
56
72
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
type $$ComponentProps = {
|
|
2
|
-
value: Date;
|
|
2
|
+
value: Date | '';
|
|
3
3
|
class?: string;
|
|
4
4
|
disabled?: boolean;
|
|
5
|
-
nodate?: Date;
|
|
5
|
+
nodate?: Date | '';
|
|
6
6
|
min?: Date;
|
|
7
7
|
max?: Date;
|
|
8
|
+
onchange?: (e: Event) => void;
|
|
8
9
|
};
|
|
9
10
|
declare const InputDatetime: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
10
11
|
type InputDatetime = ReturnType<typeof InputDatetime>;
|
package/dist/auth.svelte.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ export type User = {
|
|
|
4
4
|
name: string;
|
|
5
5
|
groups: string[];
|
|
6
6
|
roles: string[];
|
|
7
|
+
ident_uri: string;
|
|
8
|
+
created: Date | undefined;
|
|
9
|
+
modified: Date | undefined;
|
|
7
10
|
};
|
|
8
11
|
declare const auth: {
|
|
9
12
|
user: User | undefined;
|
|
@@ -15,6 +18,6 @@ declare const auth: {
|
|
|
15
18
|
fetchUser(): void;
|
|
16
19
|
login(login: string, password: string): Promise<unknown>;
|
|
17
20
|
logout(): void;
|
|
18
|
-
fetch: (url: string, method?: string, data?: any) => Promise<Response>;
|
|
21
|
+
fetch: (url: string, method?: string, data?: any, timeout?: number) => Promise<Response>;
|
|
19
22
|
};
|
|
20
23
|
export { auth };
|
package/dist/auth.svelte.js
CHANGED
|
@@ -10,6 +10,10 @@ const auth = $state({
|
|
|
10
10
|
user.name = user.login;
|
|
11
11
|
}
|
|
12
12
|
this.user = user;
|
|
13
|
+
if (this.user) {
|
|
14
|
+
this.user.created = this.user?.created ? new Date(this.user.created) : undefined;
|
|
15
|
+
this.user.modified = this.user?.modified ? new Date(this.user.modified) : undefined;
|
|
16
|
+
}
|
|
13
17
|
this.isAdmin = !!user && user.login === "admin";
|
|
14
18
|
this.isUser = !!user && user.login != "";
|
|
15
19
|
},
|
|
@@ -20,9 +24,7 @@ const auth = $state({
|
|
|
20
24
|
this.fetchUser();
|
|
21
25
|
},
|
|
22
26
|
fetchUser() {
|
|
23
|
-
|
|
24
|
-
setTimeout(() => controller.abort(), 2000);
|
|
25
|
-
fetch("/api/me", { signal: controller.signal })
|
|
27
|
+
fetch("/api/me", { signal: AbortSignal.timeout(5000) })
|
|
26
28
|
.then((resp) => {
|
|
27
29
|
switch (resp.status) {
|
|
28
30
|
case 200:
|
|
@@ -42,11 +44,9 @@ const auth = $state({
|
|
|
42
44
|
},
|
|
43
45
|
login(login, password) {
|
|
44
46
|
return new Promise((resolve, reject) => {
|
|
45
|
-
const controller = new AbortController();
|
|
46
|
-
setTimeout(() => controller.abort("Timeout waiting for response"), 3000);
|
|
47
47
|
fetch("/api/login", {
|
|
48
48
|
method: "post",
|
|
49
|
-
signal:
|
|
49
|
+
signal: AbortSignal.timeout(5000),
|
|
50
50
|
body: JSON.stringify({
|
|
51
51
|
login,
|
|
52
52
|
password
|
|
@@ -75,11 +75,11 @@ const auth = $state({
|
|
|
75
75
|
},
|
|
76
76
|
logout() {
|
|
77
77
|
auth.setUser(undefined);
|
|
78
|
-
fetch("/api/logout").then((resp) => {
|
|
78
|
+
fetch("/api/logout", { signal: AbortSignal.timeout(5000) }).then((resp) => {
|
|
79
79
|
auth.fetchUser();
|
|
80
80
|
});
|
|
81
81
|
},
|
|
82
|
-
fetch: (url, method = 'get', data = undefined) => {
|
|
82
|
+
fetch: (url, method = 'get', data = undefined, timeout = 5000) => {
|
|
83
83
|
return new Promise((resolve, reject) => {
|
|
84
84
|
let body;
|
|
85
85
|
let headers = {
|
|
@@ -94,6 +94,7 @@ const auth = $state({
|
|
|
94
94
|
body,
|
|
95
95
|
mode: "cors",
|
|
96
96
|
headers,
|
|
97
|
+
signal: AbortSignal.timeout(timeout)
|
|
97
98
|
}).then(response => {
|
|
98
99
|
// response only can be ok in range of 2XX
|
|
99
100
|
if (response.ok) {
|
package/dist/colours.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function getMidrangeWebSafeColors() {
|
|
2
|
+
// const steps = [0x00, 0x33, 0x66, 0x99, 0xCC, 0xFF];
|
|
3
|
+
const steps = [0x00, 0x33, 0x66, 0x99, 0xCC, 0xFF];
|
|
4
|
+
const colors = [];
|
|
5
|
+
// Generate all 216 web-safe colors
|
|
6
|
+
for (const r of steps) {
|
|
7
|
+
for (const g of steps) {
|
|
8
|
+
for (const b of steps) {
|
|
9
|
+
// Compute perceived luminance (0–255)
|
|
10
|
+
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
11
|
+
// Keep midrange only (avoid very dark/light)
|
|
12
|
+
if (lum > 60 && lum < 200) {
|
|
13
|
+
colors.push({
|
|
14
|
+
hex: `#${r.toString(16).padStart(2, '0')}${g
|
|
15
|
+
.toString(16)
|
|
16
|
+
.padStart(2, '0')}${b.toString(16).padStart(2, '0')}`,
|
|
17
|
+
lum,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Sort by luminance
|
|
24
|
+
colors.sort((a, b) => a.lum - b.lum);
|
|
25
|
+
// Reorder to maximize contrast — alternate from dark to light ends
|
|
26
|
+
const contrasted = [];
|
|
27
|
+
let left = 0;
|
|
28
|
+
let right = colors.length - 1;
|
|
29
|
+
while (left <= right) {
|
|
30
|
+
contrasted.push(colors[left++].hex);
|
|
31
|
+
if (left <= right)
|
|
32
|
+
contrasted.push(colors[right--].hex);
|
|
33
|
+
}
|
|
34
|
+
return contrasted;
|
|
35
|
+
}
|
|
36
|
+
// A map of different colours to use per radio ident
|
|
37
|
+
export const colors = [
|
|
38
|
+
'#3300ff',
|
|
39
|
+
'#006600',
|
|
40
|
+
'#ff9900',
|
|
41
|
+
'#660066',
|
|
42
|
+
'#3399cc',
|
|
43
|
+
'#ffcccc',
|
|
44
|
+
'#ccff00',
|
|
45
|
+
'#996600',
|
|
46
|
+
'#ff6666',
|
|
47
|
+
'#003399',
|
|
48
|
+
'#00ffff',
|
|
49
|
+
'#66cc66',
|
|
50
|
+
'#00ff33',
|
|
51
|
+
'#9933cc',
|
|
52
|
+
'#009966',
|
|
53
|
+
'#66ffcc',
|
|
54
|
+
'#9999ff',
|
|
55
|
+
'#ff00ff',
|
|
56
|
+
'#00ff99',
|
|
57
|
+
'#666666',
|
|
58
|
+
'#66cc00',
|
|
59
|
+
'#990000',
|
|
60
|
+
'#cc0066'
|
|
61
|
+
];
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
export type GeoFence = {
|
|
2
|
-
id
|
|
2
|
+
id?: number;
|
|
3
3
|
name: string;
|
|
4
|
+
points?: string;
|
|
4
5
|
size?: number;
|
|
5
6
|
};
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
load: () => Promise<void>;
|
|
7
|
+
export declare const geofence: {
|
|
8
|
+
readonly all: GeoFence[];
|
|
9
|
+
get: (id: number) => Promise<GeoFence>;
|
|
10
|
+
save: (g: GeoFence) => Promise<number>;
|
|
11
|
+
load: () => Promise<unknown>;
|
|
12
12
|
};
|
|
13
|
-
export { geofence, };
|
package/dist/geofence.svelte.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
load: (() => {
|
|
1
|
+
function createStore() {
|
|
2
|
+
let data = $state([]);
|
|
3
|
+
let loaded = false;
|
|
4
|
+
async function load() {
|
|
5
|
+
loaded = true;
|
|
7
6
|
return new Promise((resolve, reject) => {
|
|
8
|
-
fetch('/api/module/entitygeofence?select=id,name,size&order=size desc')
|
|
7
|
+
fetch('/api/module/entitygeofence?select=id,name,size&order=size desc', { signal: AbortSignal.timeout(5000) })
|
|
9
8
|
.then(resp => {
|
|
10
9
|
switch (resp.status) {
|
|
11
10
|
case 200:
|
|
12
|
-
resp.json().then(
|
|
13
|
-
|
|
14
|
-
resolve();
|
|
11
|
+
resp.json().then(dat => {
|
|
12
|
+
data = dat;
|
|
13
|
+
resolve(data);
|
|
15
14
|
}).catch(err => {
|
|
16
15
|
reject(err);
|
|
17
16
|
});
|
|
@@ -24,7 +23,65 @@ const geofence = $state({
|
|
|
24
23
|
reject(err);
|
|
25
24
|
});
|
|
26
25
|
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
}
|
|
27
|
+
async function get(id) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
fetch(`/api/module/entitygeofence/${id}`, { signal: AbortSignal.timeout(5000) })
|
|
30
|
+
.then(resp => {
|
|
31
|
+
switch (resp.status) {
|
|
32
|
+
case 200:
|
|
33
|
+
return resp.json();
|
|
34
|
+
default:
|
|
35
|
+
return resp.text().then(t => {
|
|
36
|
+
throw new Error(t);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}).then(dat => {
|
|
40
|
+
resolve(dat);
|
|
41
|
+
})
|
|
42
|
+
.catch(err => {
|
|
43
|
+
console.log("err", err);
|
|
44
|
+
reject(err);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function save(g) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let url = `/api/module/entitygeofence`;
|
|
51
|
+
let method = "post";
|
|
52
|
+
if (g.id) {
|
|
53
|
+
url += `/${g.id}`;
|
|
54
|
+
method = "put";
|
|
55
|
+
}
|
|
56
|
+
fetch(url, { method, body: JSON.stringify(g), signal: AbortSignal.timeout(5000) })
|
|
57
|
+
.then(resp => {
|
|
58
|
+
switch (resp.status) {
|
|
59
|
+
case 200:
|
|
60
|
+
load();
|
|
61
|
+
// api is not returning the new id
|
|
62
|
+
return resp.text();
|
|
63
|
+
default:
|
|
64
|
+
return resp.text().then(t => {
|
|
65
|
+
throw new Error(t);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}).then(t => {
|
|
69
|
+
resolve(parseInt(t));
|
|
70
|
+
})
|
|
71
|
+
.catch(err => {
|
|
72
|
+
reject(err);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
get all() {
|
|
78
|
+
if (!loaded)
|
|
79
|
+
load();
|
|
80
|
+
return data;
|
|
81
|
+
},
|
|
82
|
+
get,
|
|
83
|
+
save,
|
|
84
|
+
load
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export const geofence = createStore();
|
package/dist/ghistory.svelte.js
CHANGED
|
@@ -3,29 +3,21 @@ const ghistory = $state({
|
|
|
3
3
|
state: [],
|
|
4
4
|
loadState() {
|
|
5
5
|
return new Promise((resolve, reject) => {
|
|
6
|
-
fetch(`/api/module/entityhistory/state
|
|
6
|
+
fetch(`/api/module/entityhistory/state`, { signal: AbortSignal.timeout(10000) })
|
|
7
7
|
.then(resp => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
catch (e) { }
|
|
18
|
-
}
|
|
19
|
-
return s;
|
|
20
|
-
});
|
|
21
|
-
resolve();
|
|
22
|
-
}).catch(err => {
|
|
23
|
-
reject(err);
|
|
8
|
+
if (resp.status === 200) {
|
|
9
|
+
resp.json().then(data => {
|
|
10
|
+
ghistory.state = data.map(s => {
|
|
11
|
+
s.date = new Date(s.timestamp);
|
|
12
|
+
if (s.data) {
|
|
13
|
+
s.data = JSON.parse(s.data);
|
|
14
|
+
}
|
|
15
|
+
return s;
|
|
24
16
|
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
reject(
|
|
28
|
-
|
|
17
|
+
resolve();
|
|
18
|
+
}).catch(err => {
|
|
19
|
+
reject(err);
|
|
20
|
+
});
|
|
29
21
|
}
|
|
30
22
|
}).catch(err => {
|
|
31
23
|
reject(err);
|
|
@@ -50,28 +42,24 @@ const ghistory = $state({
|
|
|
50
42
|
}
|
|
51
43
|
fetch(`/api/module/entityhistory/log?${q.toString()}`)
|
|
52
44
|
.then(resp => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
l.data = JSON.parse(l.data);
|
|
61
|
-
}
|
|
62
|
-
catch (e) { }
|
|
45
|
+
if (resp.status === 200) {
|
|
46
|
+
resp.json().then(data => {
|
|
47
|
+
ghistory.log = data.map((l) => {
|
|
48
|
+
l.date = new Date(l.timestamp);
|
|
49
|
+
if (l.data) {
|
|
50
|
+
try {
|
|
51
|
+
l.data = JSON.parse(l.data);
|
|
63
52
|
}
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
}).catch(err => {
|
|
68
|
-
reject(err);
|
|
53
|
+
catch (e) { }
|
|
54
|
+
}
|
|
55
|
+
return l;
|
|
69
56
|
});
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
reject(
|
|
73
|
-
|
|
57
|
+
resolve();
|
|
58
|
+
}).catch(err => {
|
|
59
|
+
reject(err);
|
|
60
|
+
});
|
|
74
61
|
}
|
|
62
|
+
reject(resp.statusText);
|
|
75
63
|
}).catch(err => {
|
|
76
64
|
reject(err);
|
|
77
65
|
});
|
package/dist/gtime.svelte.d.ts
CHANGED
package/dist/gtime.svelte.js
CHANGED
|
@@ -8,11 +8,10 @@ let gtime = $state({
|
|
|
8
8
|
ok: true,
|
|
9
9
|
});
|
|
10
10
|
let ticks = 0; // Number of ticks since last fetch
|
|
11
|
+
let timer = 0;
|
|
11
12
|
const fetchTime = () => {
|
|
12
13
|
const start = new Date().getTime();
|
|
13
|
-
|
|
14
|
-
setTimeout(() => controller.abort(), 2000);
|
|
15
|
-
fetch("/api/date", { signal: controller.signal })
|
|
14
|
+
fetch("/api/date", { signal: AbortSignal.timeout(2000) })
|
|
16
15
|
.then((resp) => {
|
|
17
16
|
gtime.ok = false;
|
|
18
17
|
if (resp.ok) {
|
|
@@ -35,7 +34,17 @@ setInterval(() => {
|
|
|
35
34
|
ticks++;
|
|
36
35
|
if (ticks > fetchIntervalSecs) {
|
|
37
36
|
ticks = 0;
|
|
38
|
-
setTimeout(fetchTime, 0);
|
|
37
|
+
timer = setTimeout(fetchTime, 0);
|
|
39
38
|
}
|
|
40
39
|
}, 1000);
|
|
41
|
-
|
|
40
|
+
const syncTimeToBrowser = async () => {
|
|
41
|
+
const body = JSON.stringify({ "timestamp": Math.round(new Date().getTime() / 1000) });
|
|
42
|
+
const resp = await fetch("/api/date", { method: "POST", body });
|
|
43
|
+
if (resp.status === 200) {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
timer = setTimeout(fetchTime, 0);
|
|
46
|
+
return "ok";
|
|
47
|
+
}
|
|
48
|
+
throw new Error(resp.statusText);
|
|
49
|
+
};
|
|
50
|
+
export { gtime, syncTimeToBrowser };
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ import InputDebounced from "./InputDebounced.svelte";
|
|
|
9
9
|
import InputIdentUri from "./InputIdentUri.svelte";
|
|
10
10
|
import LoginForm from "./LoginForm.svelte";
|
|
11
11
|
import { recording, type Recording } from "./recording.svelte.js";
|
|
12
|
+
import AudioTimeLinePlayer from "./AudioTimeLinePlayer.svelte";
|
|
12
13
|
import VoicePlayer from "./VoicePlayer.svelte";
|
|
13
14
|
import csv from "./csv.js";
|
|
14
|
-
export { auth, gtime, geofence, device, ghistory, InputDate, InputDatetime, InputDebounced, InputIdentUri, LoginForm, recording, VoicePlayer, csv, };
|
|
15
|
+
export { auth, gtime, geofence, device, ghistory, InputDate, InputDatetime, InputDebounced, InputIdentUri, LoginForm, recording, AudioTimeLinePlayer, VoicePlayer, csv, };
|
|
15
16
|
export type { User, GeoFence, Device, Log, State, Recording };
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import InputDebounced from "./InputDebounced.svelte";
|
|
|
10
10
|
import InputIdentUri from "./InputIdentUri.svelte";
|
|
11
11
|
import LoginForm from "./LoginForm.svelte";
|
|
12
12
|
import { recording } from "./recording.svelte.js";
|
|
13
|
+
import AudioTimeLinePlayer from "./AudioTimeLinePlayer.svelte";
|
|
13
14
|
import VoicePlayer from "./VoicePlayer.svelte";
|
|
14
15
|
import csv from "./csv.js";
|
|
15
|
-
export { auth, gtime, geofence, device, ghistory, InputDate, InputDatetime, InputDebounced, InputIdentUri, LoginForm, recording, VoicePlayer, csv, };
|
|
16
|
+
export { auth, gtime, geofence, device, ghistory, InputDate, InputDatetime, InputDebounced, InputIdentUri, LoginForm, recording, AudioTimeLinePlayer, VoicePlayer, csv, };
|
|
@@ -8,7 +8,9 @@ export type Recording = {
|
|
|
8
8
|
};
|
|
9
9
|
declare const recording: {
|
|
10
10
|
all: Recording[];
|
|
11
|
+
loadedHours: string[];
|
|
11
12
|
clear: () => void;
|
|
13
|
+
getRecordings: (start: Date, end: Date) => Promise<Recording[]>;
|
|
12
14
|
loadHour: (date?: Date) => Promise<void>;
|
|
13
15
|
};
|
|
14
16
|
export { recording, };
|
package/dist/recording.svelte.js
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
const recording = $state({
|
|
2
2
|
all: [],
|
|
3
|
+
loadedHours: [], // Cache of hours loaded ["2025-10-19T19"]
|
|
3
4
|
clear: () => {
|
|
4
5
|
recording.all = [];
|
|
5
6
|
},
|
|
7
|
+
getRecordings: async (start, end) => {
|
|
8
|
+
// Check we have the required hours loaded, befire
|
|
9
|
+
// before return the requested hours
|
|
10
|
+
// Calc the end date/time
|
|
11
|
+
// Set Start the begining of the hour
|
|
12
|
+
let d = new Date(start);
|
|
13
|
+
d.setMilliseconds(0);
|
|
14
|
+
d.setSeconds(0);
|
|
15
|
+
d.setMinutes(0);
|
|
16
|
+
for (; d < end; d = new Date(d.getTime() + 1000 * 60 * 60)) {
|
|
17
|
+
const hr = d.toISOString().slice(0, 13);
|
|
18
|
+
if (!recording.loadedHours.find(h => h == hr)) {
|
|
19
|
+
await recording.loadHour(d);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return recording.all.filter(r => r.start < end && (r.start.getTime() + r.dur * 1000) > start.getTime());
|
|
23
|
+
},
|
|
6
24
|
loadHour: ((date) => {
|
|
7
25
|
return new Promise((resolve, reject) => {
|
|
8
26
|
if (!date)
|
|
9
27
|
date = new Date();
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
28
|
+
const [ymd, time] = date.toISOString().split("T");
|
|
29
|
+
const [year, month, day] = ymd.split("-");
|
|
30
|
+
const [hour, min, sec] = time.split(":");
|
|
13
31
|
fetch(`/api/recordings/${year}/${month}/${day}/${hour}`)
|
|
14
32
|
.then(resp => {
|
|
33
|
+
const hr = date.toISOString().slice(0, 13);
|
|
15
34
|
switch (resp.status) {
|
|
16
35
|
case 200:
|
|
17
36
|
resp.json().then(data => {
|
|
@@ -24,11 +43,17 @@ const recording = $state({
|
|
|
24
43
|
.reduce((m, o) => m.set(o.file, o), new Map)
|
|
25
44
|
.values());
|
|
26
45
|
recording.all.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
46
|
+
if (!recording.loadedHours.find(h => h == hr)) {
|
|
47
|
+
recording.loadedHours.push(hr);
|
|
48
|
+
}
|
|
27
49
|
resolve();
|
|
28
50
|
}).catch(err => {
|
|
29
51
|
reject(err);
|
|
30
52
|
});
|
|
31
53
|
break;
|
|
54
|
+
case 404:
|
|
55
|
+
resolve();
|
|
56
|
+
break;
|
|
32
57
|
default:
|
|
33
58
|
reject(resp.statusText);
|
|
34
59
|
break;
|