@rr0/ufoathome 0.1.0 → 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.
- package/README.md +192 -2
- package/dist-embed/UfoMessages_fr-DKnWFOyl.js +10 -0
- package/dist-embed/rr0-ufo-recorder.mjs +298 -102
- package/dist-embed-scene/UfoMessages_fr-DKnWFOyl.js +10 -0
- package/dist-embed-scene/rr0-scene.mjs +21178 -0
- package/dist-embed-ufo/UfoMessages_fr-DKnWFOyl.js +10 -0
- package/dist-embed-ufo/rr0-ufo.mjs +471 -0
- package/package.json +13 -8
- package/dist-embed-player/rr0-ufo-player.mjs +0 -275
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
const k = `
|
|
2
|
+
<div class="stage">
|
|
3
|
+
<canvas id="canvas" width="640" height="360"></canvas>
|
|
4
|
+
<div class="toolbar" id="toolbar">
|
|
5
|
+
<button id="play-pause" type="button" title="Play" aria-label="Play">▶</button>
|
|
6
|
+
<span id="time-start" class="time-label" title="Current position">0:00</span>
|
|
7
|
+
<input id="seek" type="range" min="0" max="0" value="0" step="1"/>
|
|
8
|
+
<span id="time-end" class="time-label" title="Duration">0:00</span>
|
|
9
|
+
<button id="loop" type="button" title="Auto-replay" aria-label="Auto-replay" aria-pressed="true">↻</button>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
`, x = `
|
|
13
|
+
:host {
|
|
14
|
+
display: block;
|
|
15
|
+
font-family: sans-serif;
|
|
16
|
+
}
|
|
17
|
+
.stage {
|
|
18
|
+
position: relative;
|
|
19
|
+
width: 100%;
|
|
20
|
+
}
|
|
21
|
+
canvas {
|
|
22
|
+
display: block;
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: auto;
|
|
25
|
+
aspect-ratio: 640 / 360;
|
|
26
|
+
background: var(--ufo-canvas-background, #050510);
|
|
27
|
+
border: var(--ufo-canvas-border, 1px solid #333);
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
}
|
|
30
|
+
.toolbar {
|
|
31
|
+
position: absolute;
|
|
32
|
+
left: 0;
|
|
33
|
+
right: 0;
|
|
34
|
+
bottom: 0;
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 0.5em;
|
|
38
|
+
padding: 0.4em 0.6em;
|
|
39
|
+
background: rgba(0, 0, 0, 0.55);
|
|
40
|
+
transition: opacity 0.15s ease;
|
|
41
|
+
}
|
|
42
|
+
/* While playing, the toolbar auto-hides and only reappears on hover — kept always visible while
|
|
43
|
+
paused/stopped, since that's when the user is most likely to want it. Deliberately hover-only,
|
|
44
|
+
not :focus-within: a clicked button/range input keeps keyboard focus after the pointer moves
|
|
45
|
+
away, which would otherwise keep the toolbar stuck visible indefinitely after any interaction. */
|
|
46
|
+
.toolbar.auto-hide {
|
|
47
|
+
opacity: 0;
|
|
48
|
+
pointer-events: none;
|
|
49
|
+
}
|
|
50
|
+
.stage:hover .toolbar.auto-hide {
|
|
51
|
+
opacity: 1;
|
|
52
|
+
pointer-events: auto;
|
|
53
|
+
}
|
|
54
|
+
input[type=range] {
|
|
55
|
+
flex: 1;
|
|
56
|
+
}
|
|
57
|
+
.toolbar button {
|
|
58
|
+
display: inline-flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: center;
|
|
61
|
+
width: 1.8em;
|
|
62
|
+
height: 1.8em;
|
|
63
|
+
padding: 0;
|
|
64
|
+
border-radius: 3px;
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
font-size: 1em;
|
|
67
|
+
line-height: 1;
|
|
68
|
+
}
|
|
69
|
+
.toolbar button[aria-pressed="true"] {
|
|
70
|
+
outline: 2px solid #39f;
|
|
71
|
+
}
|
|
72
|
+
.time-label {
|
|
73
|
+
color: #fff;
|
|
74
|
+
font-variant-numeric: tabular-nums;
|
|
75
|
+
font-size: 0.85em;
|
|
76
|
+
min-width: 3em;
|
|
77
|
+
text-align: center;
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
function w(i, t, e) {
|
|
81
|
+
const { bounds: s } = i;
|
|
82
|
+
return t >= s.x && t <= s.x + s.width && e >= s.y && e <= s.y + s.height;
|
|
83
|
+
}
|
|
84
|
+
class h {
|
|
85
|
+
keyframes = [];
|
|
86
|
+
addKeyframe(t, e) {
|
|
87
|
+
const s = this.findInsertIndex(t);
|
|
88
|
+
this.keyframes[s]?.t === t ? this.keyframes[s] = { t, shapes: e } : this.keyframes.splice(s, 0, { t, shapes: e });
|
|
89
|
+
}
|
|
90
|
+
findInsertIndex(t) {
|
|
91
|
+
let e = 0, s = this.keyframes.length;
|
|
92
|
+
for (; e < s; ) {
|
|
93
|
+
const a = e + s >>> 1;
|
|
94
|
+
this.keyframes[a].t < t ? e = a + 1 : s = a;
|
|
95
|
+
}
|
|
96
|
+
return e;
|
|
97
|
+
}
|
|
98
|
+
getKeyframeAt(t) {
|
|
99
|
+
const e = this.findInsertIndex(t), s = this.keyframes[e];
|
|
100
|
+
return s?.t === t ? s : void 0;
|
|
101
|
+
}
|
|
102
|
+
getShapeAt(t, e) {
|
|
103
|
+
return this.getKeyframeAt(t)?.shapes.find((s) => s.sourceId === e)?.shape;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Finds the most recent shape recorded at-or-before t for that source (hold-last-value),
|
|
107
|
+
* which is what playback needs since not every source has a keyframe at every sampled tick.
|
|
108
|
+
*/
|
|
109
|
+
getLatestShapeAt(t, e) {
|
|
110
|
+
let s = this.findInsertIndex(t);
|
|
111
|
+
for (this.keyframes[s]?.t !== t && (s -= 1); s >= 0; s--) {
|
|
112
|
+
const a = this.keyframes[s].shapes.find((n) => n.sourceId === e);
|
|
113
|
+
if (a) return a.shape;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
hitTest(t, e, s) {
|
|
117
|
+
const a = this.getKeyframeAt(t);
|
|
118
|
+
if (a) {
|
|
119
|
+
for (let n = a.shapes.length - 1; n >= 0; n--)
|
|
120
|
+
if (w(a.shapes[n].shape, e, s))
|
|
121
|
+
return a.shapes[n];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
get duration() {
|
|
125
|
+
return this.keyframes.length === 0 ? 0 : this.keyframes[this.keyframes.length - 1].t;
|
|
126
|
+
}
|
|
127
|
+
get sourceIds() {
|
|
128
|
+
const t = /* @__PURE__ */ new Set();
|
|
129
|
+
for (const e of this.keyframes)
|
|
130
|
+
for (const s of e.shapes)
|
|
131
|
+
t.add(s.sourceId);
|
|
132
|
+
return [...t];
|
|
133
|
+
}
|
|
134
|
+
get allKeyframes() {
|
|
135
|
+
return this.keyframes;
|
|
136
|
+
}
|
|
137
|
+
toJSON() {
|
|
138
|
+
return { keyframes: this.keyframes };
|
|
139
|
+
}
|
|
140
|
+
static fromJSON(t) {
|
|
141
|
+
const e = new h();
|
|
142
|
+
for (const s of t.keyframes)
|
|
143
|
+
e.addKeyframe(s.t, s.shapes);
|
|
144
|
+
return e;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function c(i) {
|
|
148
|
+
if (i.year !== void 0)
|
|
149
|
+
return Date.UTC(i.year, (i.month ?? 1) - 1, i.day ?? 1, i.hour ?? 0, i.minute ?? 0, i.second ?? 0);
|
|
150
|
+
}
|
|
151
|
+
function T(i) {
|
|
152
|
+
if (i.durationSeconds !== void 0) return i.durationSeconds * 1e3;
|
|
153
|
+
const t = i.time ? c(i.time) : void 0, e = i.endTime ? c(i.endTime) : void 0;
|
|
154
|
+
return t !== void 0 && e !== void 0 ? e - t : void 0;
|
|
155
|
+
}
|
|
156
|
+
class u {
|
|
157
|
+
constructor(t, e, s) {
|
|
158
|
+
this.event = t, this.timeline = e, this.witnessId = s;
|
|
159
|
+
}
|
|
160
|
+
static create(t, e, s) {
|
|
161
|
+
return new u({ eventType: "sighting", time: t, place: e }, new h(), s);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
class P {
|
|
165
|
+
constructor(t, e) {
|
|
166
|
+
this.timeline = t, this.onFrame = e;
|
|
167
|
+
}
|
|
168
|
+
rafId = null;
|
|
169
|
+
state = "stopped";
|
|
170
|
+
currentT = 0;
|
|
171
|
+
lastWallTime = 0;
|
|
172
|
+
/**
|
|
173
|
+
* Multiplies elapsed wall-clock time before advancing currentT. 1 (default) means playback
|
|
174
|
+
* takes as long as `timeline.duration` itself; UfoElement sets this to
|
|
175
|
+
* `timeline.duration / sightingDurationMs(event)` when the sighting's real declared duration
|
|
176
|
+
* is known, so watching it takes as long as the observation was actually reported to last,
|
|
177
|
+
* rather than however long the recording itself took to author (e.g. a quick mouse drag).
|
|
178
|
+
* Manually dragging the seek bar always jumps directly regardless of this rate.
|
|
179
|
+
*/
|
|
180
|
+
playbackRate = 1;
|
|
181
|
+
/** When true, playback restarts from 0 instead of stopping once it reaches the end. */
|
|
182
|
+
loop = !1;
|
|
183
|
+
play() {
|
|
184
|
+
if (this.state === "playing") return;
|
|
185
|
+
this.currentT >= this.timeline.duration && (this.currentT = 0, this.resolveFrame(0)), this.state = "playing", this.lastWallTime = performance.now();
|
|
186
|
+
const t = () => {
|
|
187
|
+
if (this.state !== "playing") return;
|
|
188
|
+
const e = performance.now();
|
|
189
|
+
if (this.currentT += (e - this.lastWallTime) * this.playbackRate, this.lastWallTime = e, this.currentT >= this.timeline.duration) {
|
|
190
|
+
if (this.loop && this.timeline.duration > 0) {
|
|
191
|
+
this.currentT %= this.timeline.duration, this.resolveFrame(this.currentT), this.rafId = requestAnimationFrame(t);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.currentT = this.timeline.duration, this.stop(), this.resolveFrame(this.currentT);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.resolveFrame(this.currentT), this.rafId = requestAnimationFrame(t);
|
|
198
|
+
};
|
|
199
|
+
this.rafId = requestAnimationFrame(t);
|
|
200
|
+
}
|
|
201
|
+
pause() {
|
|
202
|
+
this.state === "playing" && (this.state = "paused", this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null));
|
|
203
|
+
}
|
|
204
|
+
stop() {
|
|
205
|
+
this.state = "stopped", this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null);
|
|
206
|
+
}
|
|
207
|
+
seek(t) {
|
|
208
|
+
this.currentT = Math.max(0, Math.min(t, this.timeline.duration)), this.resolveFrame(this.currentT);
|
|
209
|
+
}
|
|
210
|
+
get playbackState() {
|
|
211
|
+
return this.state;
|
|
212
|
+
}
|
|
213
|
+
get time() {
|
|
214
|
+
return this.currentT;
|
|
215
|
+
}
|
|
216
|
+
resolveFrame(t) {
|
|
217
|
+
const e = /* @__PURE__ */ new Map();
|
|
218
|
+
for (const s of this.timeline.sourceIds) {
|
|
219
|
+
const a = this.timeline.getLatestShapeAt(t, s);
|
|
220
|
+
a && e.set(s, a);
|
|
221
|
+
}
|
|
222
|
+
this.onFrame(t, e);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const d = 6, p = d / 2, I = 20;
|
|
226
|
+
class E {
|
|
227
|
+
constructor(t) {
|
|
228
|
+
this.ctx = t;
|
|
229
|
+
}
|
|
230
|
+
clear(t, e) {
|
|
231
|
+
this.ctx.clearRect(0, 0, t, e);
|
|
232
|
+
}
|
|
233
|
+
paintShape(t) {
|
|
234
|
+
this.ctx.save(), this.ctx.globalAlpha = 1 - t.transparency, t.haloScale > 0 && this.paintHalo(t), this.paintBase(t), t.selected && this.paintSelectionHandles(t), this.ctx.restore();
|
|
235
|
+
}
|
|
236
|
+
paintBase(t) {
|
|
237
|
+
if (this.ctx.fillStyle = t.color, this.ctx.beginPath(), t.kind === "oval") {
|
|
238
|
+
const { x: e, y: s, width: a, height: n } = t.bounds, r = a / 2, o = n / 2;
|
|
239
|
+
this.ctx.ellipse(e + r, s + o, r, o, t.angle, 0, 2 * Math.PI);
|
|
240
|
+
} else {
|
|
241
|
+
const { x: e, y: s } = t.bounds;
|
|
242
|
+
this.ctx.save(), this.ctx.translate(e, s), this.ctx.rotate(t.angle), t.points.forEach((a, n) => {
|
|
243
|
+
n === 0 ? this.ctx.moveTo(a.x, a.y) : this.ctx.lineTo(a.x, a.y);
|
|
244
|
+
}), this.ctx.closePath(), this.ctx.restore();
|
|
245
|
+
}
|
|
246
|
+
this.ctx.fill();
|
|
247
|
+
}
|
|
248
|
+
paintHalo(t) {
|
|
249
|
+
this.ctx.save(), this.ctx.shadowColor = t.color, this.ctx.shadowBlur = I * t.haloScale, this.paintBase(t), this.ctx.restore();
|
|
250
|
+
}
|
|
251
|
+
paintSelectionHandles(t) {
|
|
252
|
+
const { x: e, y: s, width: a, height: n } = t.bounds, r = a / 2, o = n / 2;
|
|
253
|
+
this.ctx.strokeStyle = "lightgray", this.ctx.strokeRect(e, s, a, n), this.ctx.fillStyle = "lightgray";
|
|
254
|
+
const b = [
|
|
255
|
+
[e, s],
|
|
256
|
+
[e + r, s],
|
|
257
|
+
[e + a, s],
|
|
258
|
+
[e + a, s + o],
|
|
259
|
+
[e + a, s + n],
|
|
260
|
+
[e + r, s + n],
|
|
261
|
+
[e, s + n],
|
|
262
|
+
[e, s + o]
|
|
263
|
+
];
|
|
264
|
+
for (const [v, S] of b)
|
|
265
|
+
this.ctx.fillRect(v - p, S - p, d, d);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function M(i) {
|
|
269
|
+
return {
|
|
270
|
+
version: 1,
|
|
271
|
+
time: i.event.time,
|
|
272
|
+
endTime: i.event.endTime,
|
|
273
|
+
durationSeconds: i.event.durationSeconds,
|
|
274
|
+
place: i.event.place,
|
|
275
|
+
witnessId: i.witnessId,
|
|
276
|
+
timeline: i.timeline.toJSON()
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function L(i) {
|
|
280
|
+
return new u(
|
|
281
|
+
{
|
|
282
|
+
eventType: "sighting",
|
|
283
|
+
time: i.time,
|
|
284
|
+
endTime: i.endTime,
|
|
285
|
+
durationSeconds: i.durationSeconds,
|
|
286
|
+
place: i.place
|
|
287
|
+
},
|
|
288
|
+
h.fromJSON(i.timeline),
|
|
289
|
+
i.witnessId
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
function B(i, t) {
|
|
293
|
+
for (const e of i) {
|
|
294
|
+
const s = e.toLowerCase().split("-")[0];
|
|
295
|
+
if (t.includes(s)) return s;
|
|
296
|
+
}
|
|
297
|
+
return "en";
|
|
298
|
+
}
|
|
299
|
+
const A = ["en", "fr"], C = {
|
|
300
|
+
en: () => Promise.resolve().then(() => F).then((i) => i.ufoMessages_en),
|
|
301
|
+
fr: () => import("./UfoMessages_fr-DKnWFOyl.js").then((i) => i.ufoMessages_fr)
|
|
302
|
+
};
|
|
303
|
+
function D(i) {
|
|
304
|
+
return C[i]();
|
|
305
|
+
}
|
|
306
|
+
const g = {
|
|
307
|
+
play: "Play",
|
|
308
|
+
pause: "Pause",
|
|
309
|
+
autoReplay: "Auto-replay",
|
|
310
|
+
currentPosition: "Current position",
|
|
311
|
+
duration: "Duration"
|
|
312
|
+
}, F = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
313
|
+
__proto__: null,
|
|
314
|
+
ufoMessages_en: g
|
|
315
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
316
|
+
class R extends HTMLElement {
|
|
317
|
+
static get observedAttributes() {
|
|
318
|
+
return ["src"];
|
|
319
|
+
}
|
|
320
|
+
shadow;
|
|
321
|
+
canvas;
|
|
322
|
+
canvasRenderer;
|
|
323
|
+
toolbar;
|
|
324
|
+
playPauseButton;
|
|
325
|
+
loopButton;
|
|
326
|
+
seekInput;
|
|
327
|
+
timeStartLabel;
|
|
328
|
+
timeEndLabel;
|
|
329
|
+
currentSighting = u.create();
|
|
330
|
+
player;
|
|
331
|
+
loopEnabled = !0;
|
|
332
|
+
/** Set to false by composing elements that need the canvas's own click for something else
|
|
333
|
+
* instead of toggling playback — see UfoRecorderElement, which uses pointerdown/pointermove on
|
|
334
|
+
* this same canvas to place shapes while recording. */
|
|
335
|
+
enableClickToPlay = !0;
|
|
336
|
+
/** Matches the template's baked-in English defaults until (if ever) loadLocaleMessages()
|
|
337
|
+
* resolves a better match — see its doc comment. */
|
|
338
|
+
messages = g;
|
|
339
|
+
/** The sighting's real reported duration/start, cached by updateTimeLabels() so onFrame doesn't
|
|
340
|
+
* recompute them every animation frame — see formatPosition, which turns a `Timeline` position
|
|
341
|
+
* (ms since recording start) into what's actually displayed. */
|
|
342
|
+
realDurationMs;
|
|
343
|
+
realStartMs;
|
|
344
|
+
constructor() {
|
|
345
|
+
super(), this.shadow = this.attachShadow({ mode: "open" });
|
|
346
|
+
const t = document.createElement("template");
|
|
347
|
+
t.innerHTML = `<style>${x}</style>${k}`, this.shadow.appendChild(t.content.cloneNode(!0)), this.canvas = this.shadow.getElementById("canvas"), this.canvasRenderer = new E(this.canvas.getContext("2d")), this.toolbar = this.shadow.getElementById("toolbar"), this.playPauseButton = this.shadow.getElementById("play-pause"), this.loopButton = this.shadow.getElementById("loop"), this.seekInput = this.shadow.getElementById("seek"), this.timeStartLabel = this.shadow.getElementById("time-start"), this.timeEndLabel = this.shadow.getElementById("time-end"), this.playPauseButton.addEventListener("click", () => this.togglePlayPause()), this.loopButton.addEventListener("click", () => this.toggleLoop()), this.seekInput.addEventListener("input", () => this.player.seek(Number(this.seekInput.value))), this.canvas.addEventListener("click", () => {
|
|
348
|
+
this.enableClickToPlay && this.togglePlayPause();
|
|
349
|
+
}), this.player = this.createPlayer(), this.updateTimeLabels(), this.updatePlayPauseButton(), this.refresh(), this.loadLocaleMessages();
|
|
350
|
+
}
|
|
351
|
+
connectedCallback() {
|
|
352
|
+
const t = this.getAttribute("src");
|
|
353
|
+
t && this.loadFromSrc(t);
|
|
354
|
+
}
|
|
355
|
+
attributeChangedCallback(t, e, s) {
|
|
356
|
+
t === "src" && s && s !== e && this.isConnected && this.loadFromSrc(s);
|
|
357
|
+
}
|
|
358
|
+
/** Fetches a SightingRecordingJson from `url` and loads it — what the `src` attribute uses. */
|
|
359
|
+
async loadFromSrc(t) {
|
|
360
|
+
const e = await fetch(t);
|
|
361
|
+
this.sightingData = await e.json();
|
|
362
|
+
}
|
|
363
|
+
get sightingData() {
|
|
364
|
+
return M(this.currentSighting);
|
|
365
|
+
}
|
|
366
|
+
set sightingData(t) {
|
|
367
|
+
this.currentSighting = L(t), this.player = this.createPlayer(), this.updateTimeLabels(), this.updatePlayPauseButton(), this.refresh();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The live Sighting/Timeline, exposed so UfoRecorderElement/SceneElement
|
|
371
|
+
* (which compose this element) can add keyframes to it directly as it
|
|
372
|
+
* records, or read its time/place for lighting.
|
|
373
|
+
*/
|
|
374
|
+
get sighting() {
|
|
375
|
+
return this.currentSighting;
|
|
376
|
+
}
|
|
377
|
+
/** Exposed so UfoRecorderElement can paint a live drag preview on the same canvas. */
|
|
378
|
+
get canvasElement() {
|
|
379
|
+
return this.canvas;
|
|
380
|
+
}
|
|
381
|
+
get renderer() {
|
|
382
|
+
return this.canvasRenderer;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Re-reads the timeline's duration into the seek slider and repaints the
|
|
386
|
+
* current frame — call after externally mutating `sighting.timeline`
|
|
387
|
+
* (e.g. UfoRecorderElement adding keyframes while recording).
|
|
388
|
+
*/
|
|
389
|
+
refresh() {
|
|
390
|
+
this.seekInput.max = String(this.currentSighting.timeline.duration), this.player.seek(this.player.time);
|
|
391
|
+
}
|
|
392
|
+
onFrame(t, e) {
|
|
393
|
+
this.canvasRenderer.clear(this.canvas.width, this.canvas.height);
|
|
394
|
+
for (const s of e.values())
|
|
395
|
+
this.canvasRenderer.paintShape(s);
|
|
396
|
+
this.seekInput.value = String(t), this.timeStartLabel.textContent = this.formatPosition(t), this.updatePlayPauseButton();
|
|
397
|
+
}
|
|
398
|
+
createPlayer() {
|
|
399
|
+
const t = new P(this.currentSighting.timeline, (e, s) => this.onFrame(e, s));
|
|
400
|
+
return t.loop = this.loopEnabled, t;
|
|
401
|
+
}
|
|
402
|
+
togglePlayPause() {
|
|
403
|
+
this.player.playbackState === "playing" ? this.player.pause() : this.player.play(), this.updatePlayPauseButton();
|
|
404
|
+
}
|
|
405
|
+
updatePlayPauseButton() {
|
|
406
|
+
const t = this.player.playbackState === "playing";
|
|
407
|
+
this.playPauseButton.textContent = t ? "⏸" : "▶", this.playPauseButton.title = t ? this.messages.pause : this.messages.play, this.playPauseButton.setAttribute("aria-label", t ? this.messages.pause : this.messages.play), this.toolbar.classList.toggle("auto-hide", t);
|
|
408
|
+
}
|
|
409
|
+
toggleLoop() {
|
|
410
|
+
this.loopEnabled = !this.loopEnabled, this.loopButton.setAttribute("aria-pressed", String(this.loopEnabled)), this.player.loop = this.loopEnabled;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Auto-detects the visitor's preferred UI language from `navigator.languages`, falling back to
|
|
414
|
+
* English (already baked into the template) when none of their preferences are supported —
|
|
415
|
+
* see selectLocale. There is deliberately no language-picker UI: this is the only mechanism.
|
|
416
|
+
*/
|
|
417
|
+
async loadLocaleMessages() {
|
|
418
|
+
const t = B(navigator.languages, A);
|
|
419
|
+
t !== "en" && this.applyMessages(await D(t));
|
|
420
|
+
}
|
|
421
|
+
applyMessages(t) {
|
|
422
|
+
this.messages = t, this.timeStartLabel.title = t.currentPosition, this.timeEndLabel.title = t.duration, this.loopButton.title = t.autoReplay, this.loopButton.setAttribute("aria-label", t.autoReplay), this.updatePlayPauseButton();
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Caches the sighting's real-world reported duration/start (see sightingDurationMs) and sets
|
|
426
|
+
* the player's playback rate and the seek bar's end label from them, rather than from
|
|
427
|
+
* `timeline.duration` (how long the recording itself took to author).
|
|
428
|
+
*/
|
|
429
|
+
updateTimeLabels() {
|
|
430
|
+
const t = this.currentSighting.event, e = T(t);
|
|
431
|
+
this.realDurationMs = e !== void 0 && e > 0 ? e : void 0, this.realStartMs = t.time ? c(t.time) : void 0, this.player.playbackRate = this.realDurationMs !== void 0 ? this.currentSighting.timeline.duration / this.realDurationMs : 1, this.timeEndLabel.textContent = this.formatEndOfTimeline(), this.timeStartLabel.textContent = this.formatPosition(this.player.time);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Turns a `Timeline` position (ms since recording start, i.e. what Player deals in) into what's
|
|
435
|
+
* actually displayed: a real clock time (e.g. "02:47") when a real start/duration are both
|
|
436
|
+
* known, an elapsed real duration ("0:00" based) when only the duration is known, or the
|
|
437
|
+
* recording's own elapsed time when neither is known — see updateTimeLabels.
|
|
438
|
+
*/
|
|
439
|
+
formatPosition(t) {
|
|
440
|
+
if (this.realDurationMs === void 0) return l(t);
|
|
441
|
+
const e = this.currentSighting.timeline.duration, s = e > 0 ? t / e * this.realDurationMs : 0;
|
|
442
|
+
return this.realStartMs !== void 0 ? f(m(this.realStartMs + s)) : l(s);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The fixed end-of-timeline label — always the *full* real declared duration (clock time or
|
|
446
|
+
* elapsed), or the recording's own length when no real duration is known. Unlike
|
|
447
|
+
* formatPosition, doesn't scale by `timeline.duration`: a single-keyframe/static recording
|
|
448
|
+
* (timeline.duration === 0) still has a full declared real duration to show as its end.
|
|
449
|
+
*/
|
|
450
|
+
formatEndOfTimeline() {
|
|
451
|
+
return this.realDurationMs === void 0 ? l(this.currentSighting.timeline.duration) : this.realStartMs !== void 0 ? f(m(this.realStartMs + this.realDurationMs)) : l(this.realDurationMs);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function m(i) {
|
|
455
|
+
const t = new Date(i);
|
|
456
|
+
return { hour: t.getUTCHours(), minute: t.getUTCMinutes(), second: t.getUTCSeconds() };
|
|
457
|
+
}
|
|
458
|
+
function f(i) {
|
|
459
|
+
if (i.hour === void 0) return "0:00";
|
|
460
|
+
const t = (e) => String(e).padStart(2, "0");
|
|
461
|
+
return i.second ? `${t(i.hour)}:${t(i.minute ?? 0)}:${t(i.second)}` : `${t(i.hour)}:${t(i.minute ?? 0)}`;
|
|
462
|
+
}
|
|
463
|
+
function l(i) {
|
|
464
|
+
const t = Math.round(i / 1e3), e = Math.floor(t / 60), s = t % 60;
|
|
465
|
+
return `${e}:${String(s).padStart(2, "0")}`;
|
|
466
|
+
}
|
|
467
|
+
const y = "rr0-ufo";
|
|
468
|
+
function O() {
|
|
469
|
+
customElements.get(y) || customElements.define(y, R);
|
|
470
|
+
}
|
|
471
|
+
O();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rr0/ufoathome",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "UFO@home — record and replay a UFO sighting's shape, movement and appearance",
|
|
6
6
|
"author": "Jérôme Beau <rr0@rr0.org> (https://rr0.org)",
|
|
7
7
|
"license": "MIT",
|
|
@@ -14,20 +14,23 @@
|
|
|
14
14
|
"node": ">=20"
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
|
-
"./
|
|
18
|
-
"./recorder": "./dist-embed/rr0-ufo-recorder.mjs"
|
|
17
|
+
"./ufo": "./dist-embed-ufo/rr0-ufo.mjs",
|
|
18
|
+
"./recorder": "./dist-embed/rr0-ufo-recorder.mjs",
|
|
19
|
+
"./scene": "./dist-embed-scene/rr0-scene.mjs"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"dist-embed",
|
|
22
|
-
"dist-embed-
|
|
23
|
+
"dist-embed-ufo",
|
|
24
|
+
"dist-embed-scene"
|
|
23
25
|
],
|
|
24
26
|
"scripts": {
|
|
25
27
|
"dev": "vite",
|
|
26
28
|
"build": "tsc --noEmit && vite build",
|
|
27
29
|
"build:embed": "tsc --noEmit && vite build --config vite.embed.config.ts",
|
|
28
|
-
"build:embed-
|
|
29
|
-
"build:
|
|
30
|
-
"
|
|
30
|
+
"build:embed-ufo": "tsc --noEmit && vite build --config vite.embed-ufo.config.ts",
|
|
31
|
+
"build:embed-scene": "tsc --noEmit && vite build --config vite.embed-scene.config.ts",
|
|
32
|
+
"build:all": "npm run build && npm run build:embed && npm run build:embed-ufo && npm run build:embed-scene",
|
|
33
|
+
"prepublishOnly": "npm run build:embed && npm run build:embed-ufo && npm run build:embed-scene && npm test",
|
|
31
34
|
"preview": "vite preview",
|
|
32
35
|
"test": "vitest run",
|
|
33
36
|
"test:watch": "vitest"
|
|
@@ -35,9 +38,11 @@
|
|
|
35
38
|
"dependencies": {
|
|
36
39
|
"@rr0/data": "^0.3.40",
|
|
37
40
|
"@rr0/place": "^0.5.3",
|
|
38
|
-
"@rr0/time": "^0.11.2"
|
|
41
|
+
"@rr0/time": "^0.11.2",
|
|
42
|
+
"three": "^0.185.1"
|
|
39
43
|
},
|
|
40
44
|
"devDependencies": {
|
|
45
|
+
"@types/three": "^0.185.3",
|
|
41
46
|
"jsdom": "^25.0.0",
|
|
42
47
|
"typescript": "^5.9.2",
|
|
43
48
|
"vite": "^6.3.5",
|