three-vr-player 0.7.0 → 0.9.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/dist/Player.d.ts +4 -0
- package/dist/VideoSource-BXMPMJzW.js +722 -0
- package/dist/VideoSource-BXMPMJzW.js.map +1 -0
- package/dist/core/StereoScene.d.ts +50 -0
- package/dist/core/VRControls.d.ts +30 -1
- package/dist/core/projections.d.ts +1 -1
- package/dist/core/vr-panel-layout.d.ts +2 -1
- package/dist/core.js +1 -1
- package/dist/three-vr-player.js +42 -34
- package/dist/three-vr-player.js.map +1 -1
- package/dist/three-vr-player.standalone.js +176 -157
- package/dist/three-vr-player.standalone.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/package.json +3 -2
- package/dist/VideoSource-Cx7XPpvZ.js +0 -528
- package/dist/VideoSource-Cx7XPpvZ.js.map +0 -1
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import * as a from "three";
|
|
2
|
+
function D(h) {
|
|
3
|
+
let e = h;
|
|
4
|
+
try {
|
|
5
|
+
e = new URL(h).pathname;
|
|
6
|
+
} catch {
|
|
7
|
+
}
|
|
8
|
+
const t = e.toLowerCase();
|
|
9
|
+
return t.endsWith(".m3u8") ? "hls" : t.endsWith(".mpd") ? "dash" : "progressive";
|
|
10
|
+
}
|
|
11
|
+
const G = {
|
|
12
|
+
progressive: "/proxy/stream",
|
|
13
|
+
hls: "/proxy/hls/manifest.m3u8",
|
|
14
|
+
dash: "/proxy/mpd/manifest.m3u8"
|
|
15
|
+
};
|
|
16
|
+
function q(h, e) {
|
|
17
|
+
const t = D(h);
|
|
18
|
+
if (!e || !e.url) return { url: h, format: t };
|
|
19
|
+
const i = e.url.replace(/\/+$/, ""), s = new URLSearchParams();
|
|
20
|
+
s.set("d", h), e.apiPassword && s.set("api_password", e.apiPassword);
|
|
21
|
+
for (const [r, n] of Object.entries(e.headers ?? {}))
|
|
22
|
+
n != null && n !== "" && s.set(`h_${r}`, n);
|
|
23
|
+
return { url: `${i}${G[t]}?${s.toString()}`, format: t };
|
|
24
|
+
}
|
|
25
|
+
const b = {
|
|
26
|
+
"180-sbs": { geom: "sphere180", split: "sbs", stereo: !0 },
|
|
27
|
+
"180-mono": { geom: "sphere180", split: "mono", stereo: !1 },
|
|
28
|
+
"360-mono": { geom: "sphere360", split: "mono", stereo: !1 },
|
|
29
|
+
"360-sbs": { geom: "sphere360", split: "sbs", stereo: !0 },
|
|
30
|
+
"360-tb": { geom: "sphere360", split: "tb", stereo: !0 },
|
|
31
|
+
// DeoVR-style fisheye (~190°): a dome in front of the viewer, one circle per eye.
|
|
32
|
+
"fisheye190-sbs": { geom: "fisheye", split: "sbs", stereo: !0 },
|
|
33
|
+
"fisheye190-mono": { geom: "fisheye", split: "mono", stereo: !1 },
|
|
34
|
+
"flat-2d": { geom: "plane", split: "mono", stereo: !1, flat: !0, aspect: "full" },
|
|
35
|
+
"flat-sbs-full": { geom: "plane", split: "sbs", stereo: !0, flat: !0, aspect: "per-eye" },
|
|
36
|
+
"flat-sbs-half": { geom: "plane", split: "sbs", stereo: !0, flat: !0, aspect: "full" }
|
|
37
|
+
}, X = [
|
|
38
|
+
{ value: "180-sbs", label: "180° SBS (VR180)" },
|
|
39
|
+
{ value: "180-mono", label: "180° Mono" },
|
|
40
|
+
{ value: "360-mono", label: "360° Mono" },
|
|
41
|
+
{ value: "360-sbs", label: "360° SBS" },
|
|
42
|
+
{ value: "360-tb", label: "360° Top-Bottom" },
|
|
43
|
+
{ value: "fisheye190-sbs", label: "Fisheye 190° SBS" },
|
|
44
|
+
{ value: "fisheye190-mono", label: "Fisheye 190° Mono" },
|
|
45
|
+
{ value: "flat-2d", label: "Flat 2D" },
|
|
46
|
+
{ value: "flat-sbs-full", label: "Flat 3D — Full SBS" },
|
|
47
|
+
{ value: "flat-sbs-half", label: "Flat 3D — Half SBS" }
|
|
48
|
+
];
|
|
49
|
+
function $(h) {
|
|
50
|
+
var e;
|
|
51
|
+
return !!((e = b[h]) != null && e.flat);
|
|
52
|
+
}
|
|
53
|
+
function Y(h) {
|
|
54
|
+
const e = String(h).toLowerCase(), t = /(^|[^a-z])(tb|ou|top.?bottom|over.?under)([^a-z]|$)/.test(e), i = /(sbs|side.?by.?side)/.test(e);
|
|
55
|
+
return /(fisheye|mkx200|rf52|vrca220)/.test(e) ? e.includes("mono") ? "fisheye190-mono" : "fisheye190-sbs" : e.includes("360") ? t ? "360-tb" : i ? "360-sbs" : "360-mono" : e.includes("180") || e.includes("vr180") ? e.includes("mono") ? "180-mono" : "180-sbs" : i ? e.includes("half") ? "flat-sbs-half" : "flat-sbs-full" : null;
|
|
56
|
+
}
|
|
57
|
+
function F(h) {
|
|
58
|
+
if (!Number.isFinite(h) || h < 0) return "0:00";
|
|
59
|
+
const e = Math.floor(h % 60), t = Math.floor(h / 60 % 60), i = Math.floor(h / 3600), s = String(e).padStart(2, "0");
|
|
60
|
+
return i > 0 ? `${i}:${String(t).padStart(2, "0")}:${s}` : `${t}:${s}`;
|
|
61
|
+
}
|
|
62
|
+
const S = 1024, y = 340;
|
|
63
|
+
function Q() {
|
|
64
|
+
const h = S, e = y, t = 48;
|
|
65
|
+
return {
|
|
66
|
+
width: h,
|
|
67
|
+
height: e,
|
|
68
|
+
// Top-row icon buttons (reticle / eye / door-arrow). Compact so they read as icons, not labels.
|
|
69
|
+
recenter: { x: 30, y: 26, w: 64, h: 52 },
|
|
70
|
+
passthrough: { x: 108, y: 26, w: 64, h: 52 },
|
|
71
|
+
// toggle; shown only for alpha (passthrough) content
|
|
72
|
+
exit: { x: h - 94, y: 26, w: 64, h: 52 },
|
|
73
|
+
title: { x: 0, y: 92, w: h, h: 44 },
|
|
74
|
+
play: { x: h / 2 - 44, y: 150, w: 88, h: 88 },
|
|
75
|
+
volIcon: { x: t, y: 178, w: 44, h: 44 },
|
|
76
|
+
volBar: { x: t + 60, y: 192, w: 200, h: 16 },
|
|
77
|
+
seekBar: { x: t, y: 286, w: h - t * 2, h: 14 },
|
|
78
|
+
timeCur: { x: t, y: 262 },
|
|
79
|
+
timeDur: { x: h - t, y: 262 }
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const C = (h) => h < 0 ? 0 : h > 1 ? 1 : h;
|
|
83
|
+
function w(h, e, t, i = 0, s = 0) {
|
|
84
|
+
return e >= h.x - i && e <= h.x + h.w + i && t >= h.y - s && t <= h.y + h.h + s;
|
|
85
|
+
}
|
|
86
|
+
function V(h, e, t = Q()) {
|
|
87
|
+
return w(t.recenter, h, e) ? { region: "recenter" } : w(t.passthrough, h, e) ? { region: "passthrough" } : w(t.exit, h, e) ? { region: "exit" } : w(t.play, h, e) ? { region: "play" } : w(t.volIcon, h, e) ? { region: "volume" } : w(t.volBar, h, e, 12, 26) ? { region: "volume", value: C((h - t.volBar.x) / t.volBar.w) } : w(t.seekBar, h, e, 12, 26) ? { region: "seek", value: C((h - t.seekBar.x) / t.seekBar.w) } : null;
|
|
88
|
+
}
|
|
89
|
+
const T = "#4f8cff", M = "#e8eaed", I = "#aab2c0", W = [4, 5], E = 8e3, U = 0.05, j = 0.02, B = 1500, R = 0.15, _ = 0.012, N = 0.08;
|
|
90
|
+
class z {
|
|
91
|
+
// re-lock the panel to the head pose for a few frames after summon
|
|
92
|
+
constructor(e) {
|
|
93
|
+
this.layout = Q(), this.raycaster = new a.Raycaster(), this.tmpMatrix = new a.Matrix4(), this.tmpVec = new a.Vector3(), this.tmpQuat = new a.Quaternion(), this.prevQuat = [], this.prevPos = [], this.revealCooldownUntil = 0, this.grabbing = null, this.grabStartQuat = new a.Quaternion(), this.prevGrabQuat = new a.Quaternion(), this.grabQuat = new a.Quaternion(), this.deltaQuat = new a.Quaternion(), this.grabEuler = new a.Euler(0, 0, 0, "YXZ"), this.controllers = [], this.lasers = [], this.connected = [], this.cleanups = [], this.visible = !1, this.hover = null, this.paintKey = "", this.idleAt = 0, this.togglePrev = !1, this.placeFrames = 0, this.renderer = e.renderer, this.scene = e.scene, this.actions = e.actions, this.canvas = document.createElement("canvas"), this.canvas.width = S, this.canvas.height = y, this.ctx = this.canvas.getContext("2d"), this.texture = new a.CanvasTexture(this.canvas), this.texture.colorSpace = a.SRGBColorSpace, this.texture.minFilter = a.LinearFilter;
|
|
94
|
+
const t = 1, i = t * y / S;
|
|
95
|
+
this.panel = new a.Mesh(
|
|
96
|
+
new a.PlaneGeometry(t, i),
|
|
97
|
+
new a.MeshBasicMaterial({ map: this.texture, transparent: !0, depthTest: !1, side: a.DoubleSide })
|
|
98
|
+
), this.panel.renderOrder = 10, this.panel.frustumCulled = !1, this.panel.visible = !1, this.scene.add(this.panel), this.cursor = new a.Mesh(
|
|
99
|
+
new a.SphereGeometry(7e-3, 16, 12),
|
|
100
|
+
new a.MeshBasicMaterial({ color: 16777215, depthTest: !1 })
|
|
101
|
+
), this.cursor.renderOrder = 11, this.cursor.frustumCulled = !1, this.cursor.visible = !1, this.scene.add(this.cursor);
|
|
102
|
+
const s = this.renderer.xr.getSession(), r = s ? Array.from(s.inputSources).filter((n) => n.targetRayMode === "tracked-pointer").length : 0;
|
|
103
|
+
for (let n = 0; n < 2; n++) {
|
|
104
|
+
const l = this.renderer.xr.getController(n), o = this.makeLaser();
|
|
105
|
+
o.visible = !1, l.add(o), this.scene.add(l), this.controllers.push(l), this.lasers.push(o), this.connected.push(n < r);
|
|
106
|
+
const c = () => this.onSelect(n), p = () => this.onGrabStart(n), d = () => this.onGrabEnd(n), m = (f) => {
|
|
107
|
+
this.connected[n] = !(f != null && f.data) || f.data.targetRayMode === "tracked-pointer";
|
|
108
|
+
}, v = () => {
|
|
109
|
+
this.connected[n] = !1;
|
|
110
|
+
}, u = l;
|
|
111
|
+
u.addEventListener("selectstart", c), u.addEventListener("squeezestart", p), u.addEventListener("squeezeend", d), u.addEventListener("connected", m), u.addEventListener("disconnected", v), this.cleanups.push(() => {
|
|
112
|
+
u.removeEventListener("selectstart", c), u.removeEventListener("squeezestart", p), u.removeEventListener("squeezeend", d), u.removeEventListener("connected", m), u.removeEventListener("disconnected", v);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
this.paint(!0);
|
|
116
|
+
}
|
|
117
|
+
makeLaser() {
|
|
118
|
+
const e = new a.BufferGeometry().setFromPoints([new a.Vector3(0, 0, 0), new a.Vector3(0, 0, -1)]), t = new a.LineBasicMaterial({ color: 5213439, transparent: !0, opacity: 0.8 });
|
|
119
|
+
return new a.Line(e, t);
|
|
120
|
+
}
|
|
121
|
+
/** Called once per frame from the render loop (before renderer.render). */
|
|
122
|
+
update(e) {
|
|
123
|
+
if (!this.renderer.xr.getSession()) return;
|
|
124
|
+
if (this.pollToggle(), this.handleThumbstick(), this.handleGrab(), this.maybeRevealOnMotion(e), !this.visible) {
|
|
125
|
+
for (const s of this.lasers) s.visible = !1;
|
|
126
|
+
this.cursor.visible = !1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (this.placeFrames > 0 && (this.place(), this.placeFrames--), this.idleAt && e > this.idleAt) {
|
|
130
|
+
this.hide();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let t = null, i = null;
|
|
134
|
+
for (let s = 0; s < this.controllers.length; s++) {
|
|
135
|
+
const r = this.lasers[s], n = this.controllers[s], l = this.tmpVec.setFromMatrixPosition(n.matrixWorld).lengthSq() > 1e-6;
|
|
136
|
+
if (!this.connected[s] || !l) {
|
|
137
|
+
r.visible = !1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const o = this.rayHit(n);
|
|
141
|
+
o.point ? (r.visible = !0, r.scale.z = o.distance, i = o.point, o.hit && (t = o.hit.region)) : r.visible = !1;
|
|
142
|
+
}
|
|
143
|
+
i ? (this.cursor.position.copy(i), this.cursor.visible = !0) : this.cursor.visible = !1, t && (this.idleAt = e + E), this.hover = t, this.paint(!1);
|
|
144
|
+
}
|
|
145
|
+
/** Ray from a controller against the panel: the region under it (if any) and
|
|
146
|
+
* how far the laser reaches. */
|
|
147
|
+
rayHit(e) {
|
|
148
|
+
this.tmpMatrix.identity().extractRotation(e.matrixWorld), this.raycaster.ray.origin.setFromMatrixPosition(e.matrixWorld), this.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(this.tmpMatrix);
|
|
149
|
+
const t = this.raycaster.intersectObject(this.panel, !1);
|
|
150
|
+
if (!t.length) return { hit: null, distance: 5, point: null };
|
|
151
|
+
const i = t[0];
|
|
152
|
+
return { hit: i.uv ? V(i.uv.x * S, (1 - i.uv.y) * y, this.layout) : null, distance: i.distance, point: i.point };
|
|
153
|
+
}
|
|
154
|
+
onSelect(e) {
|
|
155
|
+
if (!this.visible || !this.connected[e]) return;
|
|
156
|
+
const { hit: t } = this.rayHit(this.controllers[e]);
|
|
157
|
+
if (t) {
|
|
158
|
+
switch (this.idleAt = P() + E, t.region) {
|
|
159
|
+
case "play":
|
|
160
|
+
this.actions.togglePlay();
|
|
161
|
+
break;
|
|
162
|
+
case "exit":
|
|
163
|
+
this.actions.exitVR();
|
|
164
|
+
break;
|
|
165
|
+
case "passthrough":
|
|
166
|
+
this.actions.passthroughAvailable() && this.actions.togglePassthrough();
|
|
167
|
+
break;
|
|
168
|
+
case "recenter":
|
|
169
|
+
this.actions.recenter(), this.placeFrames = 12;
|
|
170
|
+
break;
|
|
171
|
+
case "seek":
|
|
172
|
+
t.value !== void 0 && this.actions.seekFraction(t.value);
|
|
173
|
+
break;
|
|
174
|
+
case "volume":
|
|
175
|
+
t.value !== void 0 ? this.actions.setVolume(t.value) : this.actions.toggleMute();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
this.paint(!0);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Re-summon the panel when a connected controller is deliberately moved (and
|
|
182
|
+
* we're past the post-hide cooldown). Tracks each controller's pose per frame. */
|
|
183
|
+
maybeRevealOnMotion(e) {
|
|
184
|
+
if (this.grabbing === null)
|
|
185
|
+
for (let t = 0; t < this.controllers.length; t++) {
|
|
186
|
+
const i = this.controllers[t], s = this.tmpVec.setFromMatrixPosition(i.matrixWorld);
|
|
187
|
+
if (!this.connected[t] || s.lengthSq() <= 1e-6) continue;
|
|
188
|
+
const r = this.tmpQuat.setFromRotationMatrix(i.matrixWorld), n = this.prevQuat[t], l = this.prevPos[t];
|
|
189
|
+
n && l && !this.visible && e > this.revealCooldownUntil && (r.angleTo(n) > U || s.distanceTo(l) > j) && this.show(), this.prevQuat[t] || (this.prevQuat[t] = new a.Quaternion()), this.prevPos[t] || (this.prevPos[t] = new a.Vector3()), this.prevQuat[t].copy(r), this.prevPos[t].copy(s);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Right thumbstick X zooms the content fov every frame it's held (push right =
|
|
193
|
+
* zoom in). Pitch/yaw are handled by the grip grab-rotate, not the stick. */
|
|
194
|
+
handleThumbstick() {
|
|
195
|
+
const e = this.renderer.xr.getSession();
|
|
196
|
+
if (e)
|
|
197
|
+
for (const t of e.inputSources) {
|
|
198
|
+
if (t.handedness !== "right" || !t.gamepad) continue;
|
|
199
|
+
const i = t.gamepad.axes, s = i.length >= 4 ? i[2] ?? 0 : i[0] ?? 0, r = Math.abs(s);
|
|
200
|
+
r > R && this.actions.adjustZoom(Math.sign(s) * (r - R) / (1 - R) * _);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
onGrabStart(e) {
|
|
205
|
+
this.connected[e] && (this.grabbing = e, this.controllers[e].getWorldQuaternion(this.grabStartQuat), this.prevGrabQuat.copy(this.grabStartQuat));
|
|
206
|
+
}
|
|
207
|
+
onGrabEnd(e) {
|
|
208
|
+
this.grabbing === e && (this.grabbing = null, this.revealCooldownUntil = P() + B, this.controllers[e].getWorldQuaternion(this.grabQuat).angleTo(this.grabStartQuat) < N && this.toggle());
|
|
209
|
+
}
|
|
210
|
+
/** While the grip is held, apply the controller's frame-to-frame rotation to the
|
|
211
|
+
* content's yaw and pitch — a 1:1 grab-and-turn. Roll is ignored. */
|
|
212
|
+
handleGrab() {
|
|
213
|
+
if (this.grabbing === null) return;
|
|
214
|
+
if (!this.connected[this.grabbing]) {
|
|
215
|
+
this.grabbing = null;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const e = this.controllers[this.grabbing].getWorldQuaternion(this.grabQuat);
|
|
219
|
+
this.deltaQuat.copy(this.prevGrabQuat).invert().premultiply(e), this.grabEuler.setFromQuaternion(this.deltaQuat, "YXZ"), this.actions.adjustYaw(this.grabEuler.y), this.actions.adjustTilt(this.grabEuler.x), this.prevGrabQuat.copy(e);
|
|
220
|
+
}
|
|
221
|
+
pollToggle() {
|
|
222
|
+
const e = this.renderer.xr.getSession();
|
|
223
|
+
let t = !1;
|
|
224
|
+
for (const i of (e == null ? void 0 : e.inputSources) ?? []) {
|
|
225
|
+
const s = i.gamepad;
|
|
226
|
+
s && W.some((r) => {
|
|
227
|
+
var n;
|
|
228
|
+
return (n = s.buttons[r]) == null ? void 0 : n.pressed;
|
|
229
|
+
}) && (t = !0);
|
|
230
|
+
}
|
|
231
|
+
t && !this.togglePrev && this.toggle(), this.togglePrev = t;
|
|
232
|
+
}
|
|
233
|
+
toggle() {
|
|
234
|
+
this.visible ? this.hide() : this.show();
|
|
235
|
+
}
|
|
236
|
+
show() {
|
|
237
|
+
this.place(), this.placeFrames = 12, this.visible = !0, this.panel.visible = !0, this.idleAt = P() + E, this.paint(!0);
|
|
238
|
+
}
|
|
239
|
+
hide() {
|
|
240
|
+
this.visible = !1, this.panel.visible = !1, this.cursor.visible = !1;
|
|
241
|
+
for (const e of this.lasers) e.visible = !1;
|
|
242
|
+
this.revealCooldownUntil = P() + B;
|
|
243
|
+
}
|
|
244
|
+
/** World-lock the panel in front of the current head pose (the "recenter"). Uses
|
|
245
|
+
* yaw only (so head pitch/roll at summon doesn't skew it), drops it into the lower
|
|
246
|
+
* field of view, and tilts its face up toward the viewer. Re-run on every summon. */
|
|
247
|
+
place() {
|
|
248
|
+
const e = this.renderer.xr.getCamera(), t = e.getWorldPosition(new a.Vector3()), i = e.getWorldQuaternion(new a.Quaternion()), s = new a.Euler().setFromQuaternion(i, "YXZ").y, r = new a.Quaternion().setFromEuler(new a.Euler(0, s, 0, "YXZ")), n = new a.Vector3(0, 0, -1).applyQuaternion(r);
|
|
249
|
+
this.panel.position.copy(t).addScaledVector(n, 1.5), this.panel.position.y -= 0.55, this.panel.quaternion.copy(r), this.panel.rotateX(-0.3);
|
|
250
|
+
}
|
|
251
|
+
/** Trim text with a trailing ellipsis to fit maxWidth. Assumes ctx.font is set. */
|
|
252
|
+
ellipsize(e, t) {
|
|
253
|
+
const i = this.ctx;
|
|
254
|
+
if (i.measureText(e).width <= t) return e;
|
|
255
|
+
let s = e;
|
|
256
|
+
for (; s.length > 1 && i.measureText(s + "…").width > t; ) s = s.slice(0, -1);
|
|
257
|
+
return s.replace(/\s+$/, "") + "…";
|
|
258
|
+
}
|
|
259
|
+
roundRect(e, t, i, s, r) {
|
|
260
|
+
const n = this.ctx;
|
|
261
|
+
n.beginPath(), n.moveTo(e + r, t), n.arcTo(e + i, t, e + i, t + s, r), n.arcTo(e + i, t + s, e, t + s, r), n.arcTo(e, t + s, e, t, r), n.arcTo(e, t, e + i, t, r), n.closePath();
|
|
262
|
+
}
|
|
263
|
+
/** Repaint the panel only when something visible changed (state or hover). */
|
|
264
|
+
paint(e) {
|
|
265
|
+
const t = this.actions.currentTime(), i = this.actions.duration(), s = this.actions.volume(), r = this.actions.muted(), n = this.actions.passthroughAvailable() && this.actions.passthroughEnabled(), l = [this.actions.isPlaying(), Math.floor(t), Math.floor(i), s.toFixed(2), r, this.hover, this.actions.title(), this.actions.passthroughAvailable(), n].join("|");
|
|
266
|
+
if (!e && l === this.paintKey) return;
|
|
267
|
+
this.paintKey = l;
|
|
268
|
+
const o = this.ctx, c = this.layout;
|
|
269
|
+
o.clearRect(0, 0, S, y), this.roundRect(0, 0, S, y, 28), o.fillStyle = "rgba(22,24,30,0.84)", o.fill(), o.lineWidth = 2, o.strokeStyle = "rgba(255,255,255,0.08)", o.stroke();
|
|
270
|
+
const p = this.actions.title();
|
|
271
|
+
if (p) {
|
|
272
|
+
o.font = '600 30px system-ui,"Segoe UI",Roboto,sans-serif', o.fillStyle = M, o.textAlign = "center", o.textBaseline = "middle";
|
|
273
|
+
const u = 2 * c.exit.x - 48 - S;
|
|
274
|
+
o.fillText(this.ellipsize(p, u), S / 2, c.title.y + c.title.h / 2);
|
|
275
|
+
}
|
|
276
|
+
if (this.drawIconButton(c.recenter, this.hover === "recenter", !1, (u, f, g) => this.iconRecenter(u, f, g)), this.drawIconButton(c.exit, this.hover === "exit", !1, (u, f, g) => this.iconExit(u, f, g)), this.actions.passthroughAvailable()) {
|
|
277
|
+
const u = this.actions.passthroughEnabled();
|
|
278
|
+
this.drawIconButton(c.passthrough, u, this.hover === "passthrough", (f, g, x) => this.iconEye(f, g, !u, x));
|
|
279
|
+
}
|
|
280
|
+
const d = { x: c.play.x + c.play.w / 2, y: c.play.y + c.play.h / 2 };
|
|
281
|
+
o.beginPath(), o.arc(d.x, d.y, 44, 0, Math.PI * 2), o.fillStyle = this.hover === "play" ? T : "rgba(255,255,255,0.14)", o.fill(), o.fillStyle = "#fff", this.actions.isPlaying() ? (o.fillRect(d.x - 13, d.y - 16, 9, 32), o.fillRect(d.x + 4, d.y - 16, 9, 32)) : (o.beginPath(), o.moveTo(d.x - 12, d.y - 17), o.lineTo(d.x - 12, d.y + 17), o.lineTo(d.x + 18, d.y), o.closePath(), o.fill()), this.drawSpeaker(c.volIcon, r);
|
|
282
|
+
const m = r ? 0 : Math.max(0, Math.min(1, s));
|
|
283
|
+
this.drawBar(c.volBar, m, this.hover === "volume");
|
|
284
|
+
const v = i > 0 ? Math.max(0, Math.min(1, t / i)) : 0;
|
|
285
|
+
this.drawBar(c.seekBar, v, this.hover === "seek", !0), o.fillStyle = I, o.font = "22px system-ui,sans-serif", o.textBaseline = "alphabetic", o.textAlign = "left", o.fillText(F(t), c.timeCur.x, c.timeCur.y), o.textAlign = "right", o.fillText(i > 0 ? F(i) : "--:--", c.timeDur.x, c.timeDur.y), this.texture.needsUpdate = !0;
|
|
286
|
+
}
|
|
287
|
+
/** A rounded-square icon button: accent fill when `filled`, white border when `hover`; the
|
|
288
|
+
* icon is drawn centred in `filled ? white : TEXT`. */
|
|
289
|
+
drawIconButton(e, t, i, s) {
|
|
290
|
+
const r = this.ctx;
|
|
291
|
+
this.roundRect(e.x, e.y, e.w, e.h, 14), r.fillStyle = t ? T : "rgba(255,255,255,0.12)", r.fill(), i && (r.lineWidth = 3, r.strokeStyle = "rgba(255,255,255,0.9)", r.stroke()), s(e.x + e.w / 2, e.y + e.h / 2, t ? "#fff" : M);
|
|
292
|
+
}
|
|
293
|
+
/** Recenter — a reticle (ring + centre dot + crosshair ticks). */
|
|
294
|
+
iconRecenter(e, t, i) {
|
|
295
|
+
const s = this.ctx;
|
|
296
|
+
s.strokeStyle = i, s.fillStyle = i, s.lineWidth = 2.5, s.lineCap = "round", s.beginPath(), s.arc(e, t, 9, 0, Math.PI * 2), s.stroke(), s.beginPath(), s.arc(e, t, 2.4, 0, Math.PI * 2), s.fill(), s.beginPath(), s.moveTo(e, t - 16), s.lineTo(e, t - 6), s.moveTo(e, t + 6), s.lineTo(e, t + 16), s.moveTo(e - 16, t), s.lineTo(e - 6, t), s.moveTo(e + 6, t), s.lineTo(e + 16, t), s.stroke();
|
|
297
|
+
}
|
|
298
|
+
/** Passthrough — an eye; a diagonal slash across it means passthrough is off. */
|
|
299
|
+
iconEye(e, t, i, s) {
|
|
300
|
+
const r = this.ctx, n = 16, l = 10;
|
|
301
|
+
r.strokeStyle = s, r.fillStyle = s, r.lineWidth = 2.5, r.lineCap = "round", r.beginPath(), r.moveTo(e - n, t), r.quadraticCurveTo(e, t - l * 1.7, e + n, t), r.quadraticCurveTo(e, t + l * 1.7, e - n, t), r.stroke(), r.beginPath(), r.arc(e, t, 4, 0, Math.PI * 2), r.fill(), i && (r.beginPath(), r.moveTo(e - n, t + l), r.lineTo(e + n, t - l), r.stroke());
|
|
302
|
+
}
|
|
303
|
+
/** Exit — a door frame with an arrow pointing out to the right. */
|
|
304
|
+
iconExit(e, t, i) {
|
|
305
|
+
const s = this.ctx, r = e - 15, n = 11, l = 22, o = e + 16;
|
|
306
|
+
s.strokeStyle = i, s.lineWidth = 2.5, s.lineCap = "round", s.lineJoin = "round", s.beginPath(), s.moveTo(r + n, t - l / 2), s.lineTo(r, t - l / 2), s.lineTo(r, t + l / 2), s.lineTo(r + n, t + l / 2), s.stroke(), s.beginPath(), s.moveTo(e - 3, t), s.lineTo(o, t), s.moveTo(o - 7, t - 6), s.lineTo(o, t), s.lineTo(o - 7, t + 6), s.stroke();
|
|
307
|
+
}
|
|
308
|
+
drawBar(e, t, i, s = !1) {
|
|
309
|
+
const r = this.ctx, n = e.h / 2;
|
|
310
|
+
this.roundRect(e.x, e.y, e.w, e.h, n), r.fillStyle = "rgba(255,255,255,0.22)", r.fill(), t > 0 && (this.roundRect(e.x, e.y, e.w * t, e.h, n), r.fillStyle = T, r.fill()), (s || i) && (r.beginPath(), r.arc(e.x + e.w * t, e.y + e.h / 2, i ? 12 : 9, 0, Math.PI * 2), r.fillStyle = "#fff", r.fill());
|
|
311
|
+
}
|
|
312
|
+
drawSpeaker(e, t) {
|
|
313
|
+
const i = this.ctx, s = e.x, r = e.y + e.h / 2;
|
|
314
|
+
i.fillStyle = t ? I : M, i.beginPath(), i.moveTo(s, r - 7), i.lineTo(s + 10, r - 7), i.lineTo(s + 20, r - 16), i.lineTo(s + 20, r + 16), i.lineTo(s + 10, r + 7), i.lineTo(s, r + 7), i.closePath(), i.fill(), i.strokeStyle = t ? "#e06a6a" : M, i.lineWidth = 3, t ? (i.beginPath(), i.moveTo(s + 26, r - 9), i.lineTo(s + 40, r + 9), i.moveTo(s + 40, r - 9), i.lineTo(s + 26, r + 9), i.stroke()) : (i.beginPath(), i.arc(s + 24, r, 8, -Math.PI / 3, Math.PI / 3), i.stroke(), i.beginPath(), i.arc(s + 24, r, 15, -Math.PI / 3, Math.PI / 3), i.stroke());
|
|
315
|
+
}
|
|
316
|
+
dispose() {
|
|
317
|
+
for (const e of this.cleanups) e();
|
|
318
|
+
for (let e = 0; e < this.controllers.length; e++) {
|
|
319
|
+
const t = this.controllers[e];
|
|
320
|
+
t.remove(this.lasers[e]), this.lasers[e].geometry.dispose(), this.lasers[e].material.dispose(), this.scene.remove(t);
|
|
321
|
+
}
|
|
322
|
+
this.scene.remove(this.panel), this.panel.geometry.dispose(), this.panel.material.dispose(), this.scene.remove(this.cursor), this.cursor.geometry.dispose(), this.cursor.material.dispose(), this.texture.dispose();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function P() {
|
|
326
|
+
return typeof performance < "u" ? performance.now() : 0;
|
|
327
|
+
}
|
|
328
|
+
class Z {
|
|
329
|
+
constructor(e) {
|
|
330
|
+
this.scene = new a.Scene(), this.meshes = [], this.rig = new a.Group(), this.tilt = 0, this.fov = 1, this.builtFov = 1, this.frameCbs = [], this.alphaFisheye = !1, this.inPassthroughSession = !1, this.passthroughOn = !0, this.animate = (o) => {
|
|
331
|
+
var c;
|
|
332
|
+
for (const p of this.frameCbs) p();
|
|
333
|
+
(c = this.vrControls) == null || c.update(o ?? 0), this.video.readyState >= this.video.HAVE_CURRENT_DATA && (this.texture.needsUpdate = !0), this.renderer.render(this.scene, this.camera);
|
|
334
|
+
}, this.vrSessionInit = { optionalFeatures: ["local-floor"] }, this.arSessionInit = { optionalFeatures: ["local-floor"] }, this.vrTitle = "", this.resize = () => {
|
|
335
|
+
if (this.renderer.xr.isPresenting) return;
|
|
336
|
+
const o = this.w(), c = this.h();
|
|
337
|
+
this.camera.aspect = o / c, this.camera.updateProjectionMatrix(), this.renderer.setSize(o, c, !1);
|
|
338
|
+
};
|
|
339
|
+
const { canvas: t, video: i, projection: s = "180-sbs", swapEyes: r = !1, fov: n = 70, supersampling: l = 1.5 } = e;
|
|
340
|
+
this.canvas = t, this.video = i, this.currentMode = b[s] ? s : "180-sbs", this.currentSwap = r, this.renderer = new a.WebGLRenderer({ canvas: t, antialias: !0, alpha: !0 }), this.renderer.setClearColor(0, 1), this.renderer.setPixelRatio(this.pixelRatioFor(l)), this.renderer.setSize(this.w(), this.h(), !1), this.renderer.xr.enabled = !0, this.camera = new a.PerspectiveCamera(n, this.w() / this.h(), 0.1, 1e3), this.camera.position.set(0, 0, 0), this.maxAnisotropy = this.renderer.capabilities.getMaxAnisotropy(), this.texture = new a.VideoTexture(i), this.texture.colorSpace = a.SRGBColorSpace, this.texture.minFilter = a.LinearFilter, this.texture.magFilter = a.LinearFilter, this.texture.anisotropy = 1, this.rig.rotation.order = "YXZ", this.scene.add(this.rig), this.applyProjection(this.currentMode, this.currentSwap), i.addEventListener("loadedmetadata", () => {
|
|
341
|
+
b[this.currentMode].flat && this.applyProjection(this.currentMode, this.currentSwap);
|
|
342
|
+
}), this.renderer.setAnimationLoop(this.animate), this.renderer.xr.addEventListener("sessionstart", () => {
|
|
343
|
+
var c;
|
|
344
|
+
const o = (c = this.renderer.xr.getSession()) == null ? void 0 : c.environmentBlendMode;
|
|
345
|
+
this.inPassthroughSession = !!(o && o !== "opaque"), this.inPassthroughSession && this.setPassthrough(!0), this.buildVRControls();
|
|
346
|
+
}), this.renderer.xr.addEventListener("sessionend", () => {
|
|
347
|
+
var o;
|
|
348
|
+
this.renderer.setClearAlpha(1), this.inPassthroughSession = !1, (o = this.vrControls) == null || o.dispose(), this.vrControls = void 0, this.video.pause();
|
|
349
|
+
}), this.ro = new ResizeObserver(() => this.resize()), this.ro.observe(t);
|
|
350
|
+
}
|
|
351
|
+
buildVRControls() {
|
|
352
|
+
const e = this.video;
|
|
353
|
+
this.vrControls = new z({
|
|
354
|
+
renderer: this.renderer,
|
|
355
|
+
scene: this.scene,
|
|
356
|
+
actions: {
|
|
357
|
+
isPlaying: () => !e.paused,
|
|
358
|
+
currentTime: () => e.currentTime,
|
|
359
|
+
duration: () => e.duration || 0,
|
|
360
|
+
volume: () => e.volume,
|
|
361
|
+
muted: () => e.muted,
|
|
362
|
+
title: () => this.vrTitle,
|
|
363
|
+
togglePlay: () => {
|
|
364
|
+
e.paused ? e.play() : e.pause();
|
|
365
|
+
},
|
|
366
|
+
seekFraction: (t) => {
|
|
367
|
+
e.duration && (e.currentTime = t * e.duration);
|
|
368
|
+
},
|
|
369
|
+
setVolume: (t) => {
|
|
370
|
+
e.volume = t, t > 0 && (e.muted = !1);
|
|
371
|
+
},
|
|
372
|
+
toggleMute: () => {
|
|
373
|
+
e.muted = !e.muted;
|
|
374
|
+
},
|
|
375
|
+
exitVR: () => this.exitVR(),
|
|
376
|
+
recenter: () => this.recenter(),
|
|
377
|
+
adjustTilt: (t) => this.adjustTilt(t),
|
|
378
|
+
adjustYaw: (t) => this.adjustYaw(t),
|
|
379
|
+
adjustZoom: (t) => this.adjustZoom(t),
|
|
380
|
+
passthroughAvailable: () => this.inPassthroughSession,
|
|
381
|
+
// any AR session, not just alpha content
|
|
382
|
+
passthroughEnabled: () => this.passthroughOn,
|
|
383
|
+
togglePassthrough: () => this.setPassthrough(!this.passthroughOn)
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/** Recenter the view: make the viewer's current spot and facing the origin, so the
|
|
388
|
+
* video front is straight ahead again. Resets yaw and horizontal position; keeps
|
|
389
|
+
* head height and a level horizon (no pitch/roll). */
|
|
390
|
+
recenter() {
|
|
391
|
+
const e = this.renderer.xr;
|
|
392
|
+
if (!e.isPresenting) return;
|
|
393
|
+
const t = e.getReferenceSpace();
|
|
394
|
+
if (!t) return;
|
|
395
|
+
const i = e.getCamera(), s = i.getWorldPosition(new a.Vector3()), r = new a.Euler().setFromQuaternion(i.getWorldQuaternion(new a.Quaternion()), "YXZ").y, n = new a.Quaternion().setFromEuler(new a.Euler(0, r, 0, "YXZ")), l = new XRRigidTransform({ x: s.x, y: 0, z: s.z }, { x: n.x, y: n.y, z: n.z, w: n.w });
|
|
396
|
+
e.setReferenceSpace(t.getOffsetReferenceSpace(l));
|
|
397
|
+
}
|
|
398
|
+
/** Optional title shown on the in-VR control panel. */
|
|
399
|
+
setVRTitle(e) {
|
|
400
|
+
this.vrTitle = e;
|
|
401
|
+
}
|
|
402
|
+
w() {
|
|
403
|
+
return this.canvas.clientWidth || 1;
|
|
404
|
+
}
|
|
405
|
+
h() {
|
|
406
|
+
return this.canvas.clientHeight || 1;
|
|
407
|
+
}
|
|
408
|
+
pixelRatioFor(e) {
|
|
409
|
+
return Math.min(window.devicePixelRatio * e, 4);
|
|
410
|
+
}
|
|
411
|
+
planeAspect(e) {
|
|
412
|
+
const t = this.video.videoWidth, i = this.video.videoHeight;
|
|
413
|
+
return !t || !i ? 16 / 9 : b[e].aspect === "per-eye" ? t / 2 / i : t / i;
|
|
414
|
+
}
|
|
415
|
+
buildGeometry(e) {
|
|
416
|
+
const t = b[e].geom, i = this.fov;
|
|
417
|
+
if (t === "sphere180") {
|
|
418
|
+
const l = Math.PI * i, o = Math.PI * i, c = new a.SphereGeometry(50, 64, 32, -l / 2, l, Math.PI / 2 - o / 2, o);
|
|
419
|
+
return c.scale(-1, 1, 1), c;
|
|
420
|
+
}
|
|
421
|
+
if (t === "sphere360") {
|
|
422
|
+
const l = 2 * Math.PI * i, o = Math.PI * i, c = new a.SphereGeometry(50, 64, 32, Math.PI - l / 2, l, Math.PI / 2 - o / 2, o);
|
|
423
|
+
return c.scale(-1, 1, 1), c;
|
|
424
|
+
}
|
|
425
|
+
if (t === "fisheye") return this.buildFisheyeDome();
|
|
426
|
+
const s = 2.4 * i, r = s * this.planeAspect(e), n = new a.PlaneGeometry(r, s);
|
|
427
|
+
return n.translate(0, 0, -2), n;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* A dome covering the fisheye field of view (FISHEYE190 ≈ 190° → a 95° half-angle cap in
|
|
431
|
+
* front of the viewer), with equidistant-fisheye UVs: the angle from the forward axis maps
|
|
432
|
+
* linearly to radius on the texture disc (centre 0.5,0.5, radius 0.5). UVs are authored in
|
|
433
|
+
* full-frame [0,1] space; updateStereoUV's per-eye repeat/offset selects the L/R circle.
|
|
434
|
+
* Forward is −z (the camera's default view direction), so no extra rotation is needed.
|
|
435
|
+
*/
|
|
436
|
+
buildFisheyeDome(e = 95) {
|
|
437
|
+
const r = a.MathUtils.degToRad(e), n = [], l = [], o = [];
|
|
438
|
+
for (let d = 0; d <= 64; d++) {
|
|
439
|
+
const m = d / 64, v = m * r, u = m * 0.5, f = Math.sin(v), g = Math.cos(v);
|
|
440
|
+
for (let x = 0; x <= 128; x++) {
|
|
441
|
+
const A = x / 128 * Math.PI * 2, L = Math.cos(A), k = Math.sin(A);
|
|
442
|
+
n.push(f * L * 50, f * k * 50, -g * 50), l.push(0.5 + u * L, 0.5 + u * k);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const c = 129;
|
|
446
|
+
for (let d = 0; d < 64; d++)
|
|
447
|
+
for (let m = 0; m < 128; m++) {
|
|
448
|
+
const v = d * c + m, u = v + 1, f = v + c, g = f + 1;
|
|
449
|
+
o.push(v, f, u, u, f, g);
|
|
450
|
+
}
|
|
451
|
+
const p = new a.BufferGeometry();
|
|
452
|
+
return p.setAttribute("position", new a.Float32BufferAttribute(n, 3)), p.setAttribute("uv", new a.Float32BufferAttribute(l, 2)), p.setIndex(o), p.computeVertexNormals(), p;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Transparent material for fisheye content with a DeoVR-style packed alpha matte. Built by
|
|
456
|
+
* patching MeshBasicMaterial (so three.js keeps handling colour management, the map sample and
|
|
457
|
+
* flipY) and injecting DeoVR's exact alpha decode (ported from their WebXR player):
|
|
458
|
+
*
|
|
459
|
+
* ptUV = fract(vec2(discUV.x*0.2 + ptShift, discUV.y*0.4 + 0.8)); // ptShift 0.4 L / 0.9 R
|
|
460
|
+
* alpha = smoothstep(0.0, 0.8, average of the 3×3 red neighbourhood at ptUV);
|
|
461
|
+
*
|
|
462
|
+
* The matte is a 0.4×-scaled copy of the disc's alpha stored per eye at that offset, wrapped
|
|
463
|
+
* across the frame edges (hence it appears tucked into the corners). It's indexed by the SAME
|
|
464
|
+
* disc UV as the colour, so it aligns exactly — including per-eye parallax. Outside the disc → clip.
|
|
465
|
+
*/
|
|
466
|
+
makeFisheyeAlphaMaterial() {
|
|
467
|
+
const e = new a.MeshBasicMaterial({ map: this.texture, transparent: !0, side: a.DoubleSide, depthWrite: !1 }), t = {
|
|
468
|
+
uPtShift: { value: 0.4 },
|
|
469
|
+
uTexel: { value: new a.Vector2(1 / 8e3, 1 / 4e3) },
|
|
470
|
+
uAlphaEnabled: { value: this.passthroughOn ? 1 : 0 }
|
|
471
|
+
// 0 → opaque dome (passthrough off)
|
|
472
|
+
};
|
|
473
|
+
return this.alphaUniforms = t, e.onBeforeCompile = (i) => {
|
|
474
|
+
i.uniforms.uPtShift = t.uPtShift, i.uniforms.uTexel = t.uTexel, i.uniforms.uAlphaEnabled = t.uAlphaEnabled, i.vertexShader = `varying vec2 vDiscUv;
|
|
475
|
+
` + i.vertexShader.replace("void main() {", `void main() {
|
|
476
|
+
vDiscUv = uv;`), i.fragmentShader = `uniform float uPtShift;
|
|
477
|
+
uniform vec2 uTexel;
|
|
478
|
+
uniform float uAlphaEnabled;
|
|
479
|
+
varying vec2 vDiscUv;
|
|
480
|
+
` + i.fragmentShader.replace("#include <map_fragment>", `#include <map_fragment>
|
|
481
|
+
{
|
|
482
|
+
vec2 n = (vDiscUv - 0.5) * 2.0;
|
|
483
|
+
if (dot(n, n) > 1.0) discard; // outside the fisheye circle
|
|
484
|
+
vec2 mUv = fract(vec2(vDiscUv.x * 0.2 + uPtShift, vDiscUv.y * 0.4 + 0.8));
|
|
485
|
+
float s = 0.0; // DeoVR getMask(): 3x3 red average
|
|
486
|
+
for (int dy = -1; dy <= 1; dy++) {
|
|
487
|
+
for (int dx = -1; dx <= 1; dx++) {
|
|
488
|
+
s += texture2D(map, mUv + uTexel * vec2(float(dx), float(dy))).r;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// uAlphaEnabled 1 → keyed matte (passthrough); 0 → fully opaque dome (no passthrough)
|
|
492
|
+
diffuseColor.a *= mix(1.0, smoothstep(0.0, 0.8, s / 9.0), uAlphaEnabled);
|
|
493
|
+
}`);
|
|
494
|
+
}, e;
|
|
495
|
+
}
|
|
496
|
+
/** Passthrough on/off (immersive-ar only): on = real world visible (transparent clear); off =
|
|
497
|
+
* opaque black surround (VR-style). Alpha content additionally keys the subject via the matte;
|
|
498
|
+
* without a matte the dome stays opaque and only the surround changes. */
|
|
499
|
+
setPassthrough(e) {
|
|
500
|
+
var i;
|
|
501
|
+
this.passthroughOn = e, this.alphaUniforms && (this.alphaUniforms.uAlphaEnabled.value = e ? 1 : 0);
|
|
502
|
+
const t = (i = this.renderer.xr.getSession()) == null ? void 0 : i.environmentBlendMode;
|
|
503
|
+
t && t !== "opaque" && this.renderer.setClearAlpha(e ? 0 : 1);
|
|
504
|
+
}
|
|
505
|
+
/** Mark the current content as carrying a DeoVR-style packed alpha matte (fisheye only). */
|
|
506
|
+
setAlphaMatte(e) {
|
|
507
|
+
this.alphaFisheye !== e && (this.alphaFisheye = e, this.applyProjection(this.currentMode, this.currentSwap));
|
|
508
|
+
}
|
|
509
|
+
/** Shift the shared video texture for the eye/half being drawn. Runs from the mesh's
|
|
510
|
+
* onBeforeRender, so it's called once per eye per frame (the reference approach). */
|
|
511
|
+
updateStereoUV(e) {
|
|
512
|
+
const t = this.texture, i = b[this.currentMode].split;
|
|
513
|
+
let s = !0;
|
|
514
|
+
if (this.renderer.xr.isPresenting) {
|
|
515
|
+
const n = e, l = this.renderer.xr.getCamera().cameras;
|
|
516
|
+
l && l.length >= 2 ? n === l[0] ? s = !0 : n === l[1] ? s = !1 : s = Math.abs(n.matrixWorldInverse.elements[12] - l[0].matrixWorldInverse.elements[12]) <= Math.abs(n.matrixWorldInverse.elements[12] - l[1].matrixWorldInverse.elements[12]) : s = n.projectionMatrix.elements[8] <= 0;
|
|
517
|
+
}
|
|
518
|
+
const r = !s !== this.currentSwap;
|
|
519
|
+
if (i === "sbs" ? (t.repeat.set(0.5, 1), t.offset.set(r ? 0.5 : 0, 0)) : i === "tb" ? (t.repeat.set(1, 0.5), t.offset.set(0, r ? 0 : 0.5)) : (t.repeat.set(1, 1), t.offset.set(0, 0)), this.alphaUniforms) {
|
|
520
|
+
this.alphaUniforms.uPtShift.value = r ? 0.9 : 0.4;
|
|
521
|
+
const n = this.video.videoWidth, l = this.video.videoHeight;
|
|
522
|
+
n && l && this.alphaUniforms.uTexel.value.set(1 / n, 1 / l);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
clearMeshes() {
|
|
526
|
+
for (const e of this.meshes)
|
|
527
|
+
this.rig.remove(e), e.geometry.dispose(), e.material.dispose();
|
|
528
|
+
this.meshes.length = 0;
|
|
529
|
+
}
|
|
530
|
+
applyProjection(e, t) {
|
|
531
|
+
b[e] || (e = "180-sbs"), this.clearMeshes(), this.currentMode = e, this.currentSwap = t, this.alphaUniforms = void 0;
|
|
532
|
+
let i;
|
|
533
|
+
if (b[e].geom === "fisheye" && this.alphaFisheye)
|
|
534
|
+
i = this.makeFisheyeAlphaMaterial();
|
|
535
|
+
else {
|
|
536
|
+
const r = b[e].geom === "fisheye" ? a.DoubleSide : a.FrontSide;
|
|
537
|
+
i = new a.MeshBasicMaterial({ map: this.texture, side: r });
|
|
538
|
+
}
|
|
539
|
+
const s = new a.Mesh(this.buildGeometry(e), i);
|
|
540
|
+
s.layers.set(0), b[e].geom === "sphere180" && (s.rotation.y = Math.PI / 2), s.onBeforeRender = (r, n, l) => this.updateStereoUV(l), this.rig.add(s), this.meshes.push(s);
|
|
541
|
+
}
|
|
542
|
+
/** Pitch the video content up/down (radians, accumulated and clamped). */
|
|
543
|
+
adjustTilt(e) {
|
|
544
|
+
this.tilt = Math.max(-0.9, Math.min(0.9, this.tilt + e)), this.rig.rotation.x = this.tilt;
|
|
545
|
+
}
|
|
546
|
+
/** Yaw the video content left/right (radians, accumulated, unclamped). */
|
|
547
|
+
adjustYaw(e) {
|
|
548
|
+
this.rig.rotation.y += e;
|
|
549
|
+
}
|
|
550
|
+
/** Zoom by changing the content field of view (positive delta = zoom in / more
|
|
551
|
+
* immersive). Shrinking the fov maps the video into a smaller angular window so the
|
|
552
|
+
* scene recedes and feels farther; growing it fills the view. Rebuilds the projection
|
|
553
|
+
* geometry, throttled so it doesn't churn every frame. */
|
|
554
|
+
adjustZoom(e) {
|
|
555
|
+
this.fov = Math.max(0.3, Math.min(1, this.fov + e)), Math.abs(this.fov - this.builtFov) >= 0.03 && this.rebuildGeometry();
|
|
556
|
+
}
|
|
557
|
+
rebuildGeometry() {
|
|
558
|
+
for (const e of this.meshes) {
|
|
559
|
+
const t = e.geometry;
|
|
560
|
+
e.geometry = this.buildGeometry(this.currentMode), t.dispose();
|
|
561
|
+
}
|
|
562
|
+
this.builtFov = this.fov;
|
|
563
|
+
}
|
|
564
|
+
setProjection(e) {
|
|
565
|
+
this.applyProjection(e, this.currentSwap);
|
|
566
|
+
}
|
|
567
|
+
setSwapEyes(e) {
|
|
568
|
+
this.applyProjection(this.currentMode, e);
|
|
569
|
+
}
|
|
570
|
+
setFov(e) {
|
|
571
|
+
this.camera.fov = e, this.camera.updateProjectionMatrix();
|
|
572
|
+
}
|
|
573
|
+
setSupersampling(e) {
|
|
574
|
+
this.renderer.setPixelRatio(this.pixelRatioFor(e)), this.renderer.setSize(this.w(), this.h(), !1);
|
|
575
|
+
}
|
|
576
|
+
getProjection() {
|
|
577
|
+
return this.currentMode;
|
|
578
|
+
}
|
|
579
|
+
isFlat() {
|
|
580
|
+
return !!b[this.currentMode].flat;
|
|
581
|
+
}
|
|
582
|
+
onFrame(e) {
|
|
583
|
+
this.frameCbs.push(e);
|
|
584
|
+
}
|
|
585
|
+
pauseRendering() {
|
|
586
|
+
this.renderer.setAnimationLoop(null);
|
|
587
|
+
}
|
|
588
|
+
resumeRendering() {
|
|
589
|
+
this.renderer.setAnimationLoop(this.animate);
|
|
590
|
+
}
|
|
591
|
+
/** Enter immersive VR. Rejects with a readable message on failure so the caller can
|
|
592
|
+
* surface it — instead of the silent no-op three.js's VRButton produces. Called from
|
|
593
|
+
* a click handler so the request keeps the user's transient activation. */
|
|
594
|
+
async enterVR() {
|
|
595
|
+
if (!navigator.xr) throw new Error("WebXR is unavailable here — open the page over HTTPS or localhost.");
|
|
596
|
+
const e = await navigator.xr.requestSession("immersive-vr", this.vrSessionInit);
|
|
597
|
+
await this.renderer.xr.setSession(e);
|
|
598
|
+
}
|
|
599
|
+
/** Enter immersive AR (passthrough). Same contract as enterVR — called from a click so it
|
|
600
|
+
* keeps transient activation, rejects with a readable message. The UA composites our
|
|
601
|
+
* transparent framebuffer over the camera feed. */
|
|
602
|
+
async enterAR() {
|
|
603
|
+
if (!navigator.xr) throw new Error("WebXR is unavailable here — open the page over HTTPS or localhost.");
|
|
604
|
+
const e = await navigator.xr.requestSession("immersive-ar", this.arSessionInit);
|
|
605
|
+
await this.renderer.xr.setSession(e);
|
|
606
|
+
}
|
|
607
|
+
exitVR() {
|
|
608
|
+
var e;
|
|
609
|
+
this.video.paused || this.video.pause(), (e = this.renderer.xr.getSession()) == null || e.end();
|
|
610
|
+
}
|
|
611
|
+
/** Arm the browser/headset's own "Enter VR" affordance (e.g. the Quest system button). */
|
|
612
|
+
offerVR() {
|
|
613
|
+
var i;
|
|
614
|
+
const e = (i = navigator.xr) == null ? void 0 : i.offerSession;
|
|
615
|
+
if (!e) return;
|
|
616
|
+
const t = () => e.call(navigator.xr, "immersive-vr", this.vrSessionInit).then((s) => this.renderer.xr.setSession(s)).catch(() => {
|
|
617
|
+
});
|
|
618
|
+
this.renderer.xr.addEventListener("sessionend", () => void t()), t();
|
|
619
|
+
}
|
|
620
|
+
dispose() {
|
|
621
|
+
var e;
|
|
622
|
+
this.ro.disconnect(), this.renderer.setAnimationLoop(null), (e = this.vrControls) == null || e.dispose(), this.texture.dispose(), this.clearMeshes(), this.renderer.dispose();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const O = Math.PI / 180;
|
|
626
|
+
function H(h, e) {
|
|
627
|
+
return {
|
|
628
|
+
lon: Math.max(-90, Math.min(90, h)),
|
|
629
|
+
lat: Math.max(-85, Math.min(85, e))
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
class K {
|
|
633
|
+
constructor(e, t, i = {}) {
|
|
634
|
+
this.camera = e, this.dom = t, this.lon = 0, this.lat = 0, this.dragging = !1, this.px = 0, this.py = 0, this.enabled = !0, this.onDown = (s) => {
|
|
635
|
+
this.enabled && (this.dragging = !0, this.px = s.clientX, this.py = s.clientY, this.capture("setPointerCapture", s.pointerId));
|
|
636
|
+
}, this.onMove = (s) => {
|
|
637
|
+
if (!this.dragging) return;
|
|
638
|
+
const r = s.clientX - this.px, n = s.clientY - this.py;
|
|
639
|
+
this.px = s.clientX, this.py = s.clientY, { lon: this.lon, lat: this.lat } = H(this.lon - r * 0.15, this.lat + n * 0.15);
|
|
640
|
+
}, this.onUp = (s) => {
|
|
641
|
+
this.dragging = !1, this.capture("releasePointerCapture", s.pointerId);
|
|
642
|
+
}, this.isPresenting = i.isPresenting ?? (() => !1), this.dom.addEventListener("pointerdown", this.onDown), this.dom.addEventListener("pointermove", this.onMove), this.dom.addEventListener("pointerup", this.onUp), this.dom.addEventListener("pointercancel", this.onUp);
|
|
643
|
+
}
|
|
644
|
+
capture(e, t) {
|
|
645
|
+
var i, s;
|
|
646
|
+
try {
|
|
647
|
+
(s = (i = this.dom)[e]) == null || s.call(i, t);
|
|
648
|
+
} catch {
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
update() {
|
|
652
|
+
if (this.isPresenting() || !this.enabled) return;
|
|
653
|
+
const e = (90 - this.lat) * O, t = this.lon * O;
|
|
654
|
+
this.camera.lookAt(
|
|
655
|
+
Math.sin(e) * Math.sin(t),
|
|
656
|
+
Math.cos(e),
|
|
657
|
+
-Math.sin(e) * Math.cos(t)
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
reset() {
|
|
661
|
+
this.lon = 0, this.lat = 0;
|
|
662
|
+
}
|
|
663
|
+
setEnabled(e) {
|
|
664
|
+
this.enabled = e, e || (this.lon = 0, this.lat = 0, this.camera.lookAt(0, 0, -1));
|
|
665
|
+
}
|
|
666
|
+
getAngles() {
|
|
667
|
+
return { lon: this.lon, lat: this.lat };
|
|
668
|
+
}
|
|
669
|
+
dispose() {
|
|
670
|
+
this.dom.removeEventListener("pointerdown", this.onDown), this.dom.removeEventListener("pointermove", this.onMove), this.dom.removeEventListener("pointerup", this.onUp), this.dom.removeEventListener("pointercancel", this.onUp);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
class J {
|
|
674
|
+
constructor() {
|
|
675
|
+
this.hls = null;
|
|
676
|
+
}
|
|
677
|
+
async attach(e, t, i = {}) {
|
|
678
|
+
this.dispose();
|
|
679
|
+
const s = i.crossOrigin === void 0 ? "anonymous" : i.crossOrigin;
|
|
680
|
+
s === null ? e.removeAttribute("crossorigin") : e.crossOrigin = s, e.playsInline = !0;
|
|
681
|
+
const { url: r, format: n } = t, l = n === "hls" || n === "dash", o = e.canPlayType("application/vnd.apple.mpegurl") !== "";
|
|
682
|
+
return new Promise((c, p) => {
|
|
683
|
+
const d = () => {
|
|
684
|
+
e.removeEventListener("loadedmetadata", m), e.removeEventListener("error", v);
|
|
685
|
+
}, m = () => {
|
|
686
|
+
d(), c();
|
|
687
|
+
}, v = () => {
|
|
688
|
+
d();
|
|
689
|
+
const u = e.error ? e.error.code : "unknown";
|
|
690
|
+
p(new Error(`Video failed to load (media error ${u}). Is the source reachable and CORS-clean?`));
|
|
691
|
+
};
|
|
692
|
+
e.addEventListener("loadedmetadata", m), e.addEventListener("error", v), l && !o ? import("hls.js").then(({ default: u }) => {
|
|
693
|
+
if (!u.isSupported()) {
|
|
694
|
+
d(), p(new Error("HLS/DASH stream needs hls.js, which is unsupported in this browser."));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
this.hls = new u({ enableWorker: !0 }), this.hls.on(u.Events.ERROR, (f, g) => {
|
|
698
|
+
g.fatal && (d(), this.dispose(), p(new Error(`HLS fatal error: ${g.type} / ${g.details}`)));
|
|
699
|
+
}), this.hls.loadSource(r), this.hls.attachMedia(e);
|
|
700
|
+
}).catch(() => {
|
|
701
|
+
d(), p(new Error('This looks like an HLS/DASH stream but hls.js could not be loaded. Install "hls.js".'));
|
|
702
|
+
}) : (e.src = r, e.load());
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
dispose() {
|
|
706
|
+
this.hls && (this.hls.destroy(), this.hls = null);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
export {
|
|
710
|
+
K as L,
|
|
711
|
+
b as M,
|
|
712
|
+
X as P,
|
|
713
|
+
Z as S,
|
|
714
|
+
J as V,
|
|
715
|
+
Y as a,
|
|
716
|
+
q as b,
|
|
717
|
+
H as c,
|
|
718
|
+
D as d,
|
|
719
|
+
F as f,
|
|
720
|
+
$ as i
|
|
721
|
+
};
|
|
722
|
+
//# sourceMappingURL=VideoSource-BXMPMJzW.js.map
|