ebta-workflow-prod 4.1.6 → 4.1.8
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/build/activitypack.json +1 -1
- package/build/main.js +3 -3
- package/ebta-workflow-prod-4.0.24.tgz +0 -0
- package/package.json +2 -2
- package/src/activities/Sample.ts +2 -2
- package/src/elements/CsColorPicker.tsx +1 -2
- package/src/elements/PanovideoPlayer.tsx +772 -145
- package/src/elements/SampleInput.tsx +11 -9
- package/src/elements/SingleAutoCompleteCS.tsx +1 -0
- package/src/elements/Timeline.tsx +0 -2
- package/src/index.ts +1 -1
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
/** @jsxRuntime classic */
|
|
1
2
|
import * as React from "react";
|
|
2
3
|
import { FormElementProps, FormElementRegistration } from "@vertigis/workflow";
|
|
3
4
|
import Marzipano from "marzipano";
|
|
5
|
+
const M: any = Marzipano;
|
|
4
6
|
|
|
7
|
+
function logPanovideo(message: string, details?: unknown): void {
|
|
8
|
+
if (details === undefined) {
|
|
9
|
+
console.log(`[PanovideoPlayer] ${message}`);
|
|
10
|
+
} else {
|
|
11
|
+
console.log(`[PanovideoPlayer] ${message}`, details);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
logPanovideo("module loaded", { marzipanoAvailable: Boolean(M) });
|
|
16
|
+
|
|
17
|
+
export interface VideoResolution {
|
|
18
|
+
/** Display label for this source. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** URL of the equirectangular video. Must be served with CORS headers. */
|
|
21
|
+
url: string;
|
|
22
|
+
}
|
|
5
23
|
|
|
6
24
|
/**
|
|
7
25
|
* The generic type argument provided to `FormElementProps<T>` controls the type
|
|
@@ -13,213 +31,822 @@ import Marzipano from "marzipano";
|
|
|
13
31
|
* runtime, and passed in as `props`. You can update the properties by using
|
|
14
32
|
* `props.setProperty(key, value)`.
|
|
15
33
|
*/
|
|
16
|
-
interface PanovideoPlayerProps extends FormElementProps<string> {
|
|
34
|
+
interface PanovideoPlayerProps extends FormElementProps<string> {
|
|
35
|
+
/** URL of the equirectangular video. Takes priority over `resolutions`. */
|
|
36
|
+
value: string;
|
|
37
|
+
/** Fallback list of sources. The first entry is used when `value` is empty. */
|
|
38
|
+
resolutions?: VideoResolution[];
|
|
39
|
+
/** Start playing automatically (muted autoplay is allowed by browsers). */
|
|
40
|
+
autoplay?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* How far past 1:1 video pixels the user can zoom in.
|
|
43
|
+
* 1 = native resolution only, 3 = up to 3x magnified.
|
|
44
|
+
*/
|
|
45
|
+
maxZoom?: number;
|
|
46
|
+
}
|
|
17
47
|
|
|
18
|
-
|
|
19
|
-
|
|
48
|
+
/* -------------------------------------------------------------------------- */
|
|
49
|
+
/* VideoAsset (equivalent of the demo's VideoAsset.js) */
|
|
50
|
+
/* */
|
|
51
|
+
/* A Marzipano dynamic asset that exposes the current video frame. */
|
|
52
|
+
/* -------------------------------------------------------------------------- */
|
|
20
53
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
// Marzipano polls isDynamic()/emits('change') to know when to redraw.
|
|
25
|
-
this._element.addEventListener("timeupdate", () => {
|
|
26
|
-
this.emit("change");
|
|
27
|
-
});
|
|
28
|
-
}
|
|
54
|
+
interface VideoAsset {
|
|
55
|
+
emit(eventName: string): void;
|
|
56
|
+
}
|
|
29
57
|
|
|
30
|
-
|
|
31
|
-
|
|
58
|
+
class VideoAsset {
|
|
59
|
+
private el: HTMLVideoElement;
|
|
60
|
+
private destroyed = false;
|
|
61
|
+
private lastTime = -1;
|
|
62
|
+
private stamp = 0;
|
|
63
|
+
private raf = 0;
|
|
64
|
+
private readonly emptyCanvas: HTMLCanvasElement;
|
|
65
|
+
|
|
66
|
+
constructor(el: HTMLVideoElement) {
|
|
67
|
+
logPanovideo("VideoAsset.constructor: start", { src: el.currentSrc || el.src });
|
|
68
|
+
this.el = el;
|
|
69
|
+
this.emptyCanvas = document.createElement("canvas");
|
|
70
|
+
this.emptyCanvas.width = 1;
|
|
71
|
+
this.emptyCanvas.height = 1;
|
|
72
|
+
this.loop();
|
|
73
|
+
logPanovideo("VideoAsset.constructor: complete");
|
|
32
74
|
}
|
|
33
75
|
|
|
34
76
|
width(): number {
|
|
35
|
-
return this.
|
|
77
|
+
return this.el.videoWidth || 1;
|
|
36
78
|
}
|
|
37
79
|
|
|
38
80
|
height(): number {
|
|
39
|
-
return this.
|
|
81
|
+
return this.el.videoHeight || 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
element(): HTMLVideoElement | HTMLCanvasElement {
|
|
85
|
+
// Until a frame is available, hand WebGL a 1x1 canvas instead.
|
|
86
|
+
return this.el.readyState >= 2 ? this.el : this.emptyCanvas;
|
|
40
87
|
}
|
|
41
88
|
|
|
42
89
|
isDynamic(): boolean {
|
|
43
90
|
return true;
|
|
44
91
|
}
|
|
92
|
+
|
|
93
|
+
timestamp(): number {
|
|
94
|
+
return this.stamp;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
destroy(): void {
|
|
98
|
+
logPanovideo("VideoAsset.destroy: start");
|
|
99
|
+
this.destroyed = true;
|
|
100
|
+
cancelAnimationFrame(this.raf);
|
|
101
|
+
logPanovideo("VideoAsset.destroy: complete");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private loop = (): void => {
|
|
105
|
+
if (this.destroyed) {
|
|
106
|
+
logPanovideo("VideoAsset.loop: stopped; asset destroyed");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const t = this.el.currentTime;
|
|
110
|
+
if (t !== this.lastTime) {
|
|
111
|
+
this.lastTime = t;
|
|
112
|
+
this.stamp++;
|
|
113
|
+
this.emit("change");
|
|
114
|
+
}
|
|
115
|
+
this.raf = requestAnimationFrame(this.loop);
|
|
116
|
+
};
|
|
45
117
|
}
|
|
118
|
+
M.dependencies.eventEmitter(VideoAsset);
|
|
119
|
+
|
|
120
|
+
/* -------------------------------------------------------------------------- */
|
|
121
|
+
/* Video loading helper */
|
|
122
|
+
/* -------------------------------------------------------------------------- */
|
|
123
|
+
|
|
124
|
+
function createVideo(url: string, muted: boolean): HTMLVideoElement {
|
|
125
|
+
logPanovideo("createVideo: start", { url, muted });
|
|
126
|
+
const v = document.createElement("video");
|
|
127
|
+
v.crossOrigin = "anonymous"; // required to use the video as a WebGL texture
|
|
128
|
+
v.loop = true;
|
|
129
|
+
v.muted = muted;
|
|
130
|
+
v.preload = "auto";
|
|
131
|
+
v.playsInline = true;
|
|
132
|
+
// Prevent iOS from forcing fullscreen playback.
|
|
133
|
+
v.setAttribute("playsinline", "");
|
|
134
|
+
v.setAttribute("webkit-playsinline", "");
|
|
135
|
+
v.src = url;
|
|
136
|
+
v.load();
|
|
137
|
+
logPanovideo("createVideo: complete", { src: v.src, readyState: v.readyState });
|
|
138
|
+
return v;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function disposeVideo(v: HTMLVideoElement): void {
|
|
142
|
+
logPanovideo("disposeVideo: start", { src: v.currentSrc || v.src, readyState: v.readyState });
|
|
143
|
+
v.pause();
|
|
144
|
+
v.removeAttribute("src");
|
|
145
|
+
v.load();
|
|
146
|
+
logPanovideo("disposeVideo: complete");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatTime(seconds: number): string {
|
|
150
|
+
const wholeSeconds = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
|
|
151
|
+
const minutes = Math.floor(wholeSeconds / 60);
|
|
152
|
+
const remainingSeconds = wholeSeconds % 60;
|
|
153
|
+
return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* -------------------------------------------------------------------------- */
|
|
157
|
+
/* Constants and styles */
|
|
158
|
+
/* -------------------------------------------------------------------------- */
|
|
159
|
+
|
|
160
|
+
const DEFAULT_RESOLUTIONS: VideoResolution[] = [
|
|
161
|
+
// The first source is used by the player when `value` is empty.
|
|
162
|
+
{ label: "1280x640", url: "//win11assets.blob.core.windows.net/playbookicon/260917_BR120.mp4" },
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/** Widest vertical field of view the user can zoom out to. */
|
|
166
|
+
const MAX_VFOV = (120 * Math.PI) / 180;
|
|
167
|
+
/** Field of view the player starts with. */
|
|
168
|
+
const INITIAL_FOV = (90 * Math.PI) / 180;
|
|
169
|
+
/** Each zoom click / key press multiplies the FOV by these factors. */
|
|
170
|
+
const ZOOM_IN_FACTOR = 0.8;
|
|
171
|
+
const ZOOM_OUT_FACTOR = 1.25;
|
|
172
|
+
/** Wheel sensitivity. FOV is multiplied by exp(deltaY * WHEEL_SENSITIVITY). */
|
|
173
|
+
const WHEEL_SENSITIVITY = 0.001;
|
|
174
|
+
/** Permissive limiter used until the real video size is known. */
|
|
175
|
+
const PLACEHOLDER_MAX_RESOLUTION = 4096 * 4;
|
|
176
|
+
|
|
177
|
+
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5];
|
|
178
|
+
|
|
179
|
+
const loadingOverlayStyle: React.CSSProperties = {
|
|
180
|
+
position: "absolute",
|
|
181
|
+
inset: 0,
|
|
182
|
+
display: "flex",
|
|
183
|
+
flexDirection: "column",
|
|
184
|
+
alignItems: "center",
|
|
185
|
+
justifyContent: "center",
|
|
186
|
+
gap: 12,
|
|
187
|
+
background: "transparent",
|
|
188
|
+
color: "#fff",
|
|
189
|
+
fontSize: 14,
|
|
190
|
+
fontWeight: 600,
|
|
191
|
+
textShadow: "0 1px 4px rgba(0,0,0,0.9)",
|
|
192
|
+
zIndex: 5,
|
|
193
|
+
pointerEvents: "none",
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const spinnerStyle: React.CSSProperties = {
|
|
197
|
+
width: 44,
|
|
198
|
+
height: 44,
|
|
199
|
+
borderRadius: "50%",
|
|
200
|
+
border: "4px solid rgba(255,255,255,0.35)",
|
|
201
|
+
borderTopColor: "#fff",
|
|
202
|
+
filter: "drop-shadow(0 1px 4px rgba(0,0,0,0.8))",
|
|
203
|
+
animation: "panovideo-spin 0.9s linear infinite",
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
const barStyle: React.CSSProperties = {
|
|
208
|
+
position: "absolute",
|
|
209
|
+
left: 8,
|
|
210
|
+
right: 8,
|
|
211
|
+
bottom: 8,
|
|
212
|
+
display: "flex",
|
|
213
|
+
gap: 8,
|
|
214
|
+
alignItems: "center",
|
|
215
|
+
minWidth: 0,
|
|
216
|
+
padding: "6px 8px",
|
|
217
|
+
background: "rgba(0,0,0,0.55)",
|
|
218
|
+
borderRadius: 4,
|
|
219
|
+
color: "#fff",
|
|
220
|
+
fontSize: 13,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const btnStyle: React.CSSProperties = {
|
|
224
|
+
background: "transparent",
|
|
225
|
+
border: "1px solid rgba(255,255,255,0.6)",
|
|
226
|
+
borderRadius: 3,
|
|
227
|
+
color: "#fff",
|
|
228
|
+
cursor: "pointer",
|
|
229
|
+
minWidth: 32,
|
|
230
|
+
height: 28,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
const speedStyle: React.CSSProperties = {
|
|
235
|
+
...btnStyle,
|
|
236
|
+
background: "rgba(0,0,0,0.55)",
|
|
237
|
+
padding: "0 4px",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const zoomControlsStyle: React.CSSProperties = {
|
|
241
|
+
position: "absolute",
|
|
242
|
+
top: 8,
|
|
243
|
+
left: 8,
|
|
244
|
+
display: "flex",
|
|
245
|
+
flexDirection: "column",
|
|
246
|
+
gap: 6,
|
|
247
|
+
zIndex: 10000,
|
|
248
|
+
pointerEvents: "auto",
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const zoomBtnStyle: React.CSSProperties = {
|
|
252
|
+
...btnStyle,
|
|
253
|
+
width: 34,
|
|
254
|
+
height: 34,
|
|
255
|
+
fontSize: 20,
|
|
256
|
+
fontWeight: 600,
|
|
257
|
+
background: "rgba(0,0,0,0.55)",
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const timelineStyle: React.CSSProperties = {
|
|
261
|
+
flex: "1 1 auto",
|
|
262
|
+
minWidth: 40,
|
|
263
|
+
height: 20,
|
|
264
|
+
cursor: "pointer",
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const timeStyle: React.CSSProperties = {
|
|
268
|
+
whiteSpace: "nowrap",
|
|
269
|
+
fontVariantNumeric: "tabular-nums",
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/* -------------------------------------------------------------------------- */
|
|
273
|
+
/* Component */
|
|
274
|
+
/* -------------------------------------------------------------------------- */
|
|
46
275
|
|
|
47
276
|
/**
|
|
48
|
-
* A Workflow element built using React
|
|
277
|
+
* A Workflow element built using React
|
|
49
278
|
* @displayName PanovideoPlayer
|
|
50
279
|
* @description Marzipano library panoramic video player
|
|
51
280
|
* @param props The props that will be provided by the Workflow runtime.
|
|
52
281
|
*/
|
|
53
282
|
function PanovideoPlayer(props: PanovideoPlayerProps): React.ReactElement {
|
|
54
|
-
const {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
283
|
+
const { value, resolutions = DEFAULT_RESOLUTIONS, autoplay = true, maxZoom = 3, raiseEvent } = props;
|
|
284
|
+
const sourceUrl = value || resolutions[0]?.url;
|
|
285
|
+
|
|
286
|
+
logPanovideo("component render: start", {
|
|
287
|
+
sourceUrl,
|
|
288
|
+
resolutionsCount: resolutions.length,
|
|
289
|
+
autoplay,
|
|
290
|
+
maxZoom,
|
|
291
|
+
hasSetValue: typeof props.setValue === "function",
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const playerRef = React.useRef<HTMLDivElement>(null);
|
|
295
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
58
296
|
const viewerRef = React.useRef<any>(null);
|
|
59
|
-
const
|
|
60
|
-
const
|
|
297
|
+
const viewRef = React.useRef<any>(null);
|
|
298
|
+
const assetRef = React.useRef<VideoAsset | null>(null);
|
|
299
|
+
const videoRef = React.useRef<HTMLVideoElement | null>(null);
|
|
300
|
+
|
|
301
|
+
// Latest prop values for use inside effects that must not re-run on change.
|
|
302
|
+
const maxZoomRef = React.useRef(maxZoom);
|
|
303
|
+
maxZoomRef.current = maxZoom;
|
|
304
|
+
const autoplayRef = React.useRef(autoplay);
|
|
305
|
+
autoplayRef.current = autoplay;
|
|
306
|
+
const raiseEventRef = React.useRef(raiseEvent);
|
|
307
|
+
raiseEventRef.current = raiseEvent;
|
|
308
|
+
|
|
309
|
+
const [playing, setPlaying] = React.useState(false);
|
|
310
|
+
const [muted, setMuted] = React.useState(true);
|
|
311
|
+
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
|
312
|
+
const [currentTime, setCurrentTime] = React.useState(0);
|
|
313
|
+
const [duration, setDuration] = React.useState(0);
|
|
314
|
+
const [error, setError] = React.useState<string>();
|
|
315
|
+
|
|
316
|
+
const [loading, setLoading] = React.useState(true);
|
|
317
|
+
const [loadPercent, setLoadPercent] = React.useState(0);
|
|
318
|
+
const [playbackRate, setPlaybackRate] = React.useState(1);
|
|
319
|
+
const playbackRateRef = React.useRef(1);
|
|
320
|
+
|
|
321
|
+
/* ---- zoom limit, derived from the real video resolution ---- */
|
|
322
|
+
const applyZoomLimit = (reason: string) => {
|
|
323
|
+
const view = viewRef.current;
|
|
324
|
+
const video = videoRef.current;
|
|
325
|
+
if (!view || !video || !video.videoWidth) {
|
|
326
|
+
logPanovideo("applyZoomLimit: skipped", {
|
|
327
|
+
reason,
|
|
328
|
+
hasView: Boolean(view),
|
|
329
|
+
hasVideo: Boolean(video),
|
|
330
|
+
videoWidth: video?.videoWidth,
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const effectiveWidth = video.videoWidth * Math.max(1, maxZoomRef.current);
|
|
335
|
+
view.setLimiter(M.RectilinearView.limit.traditional(effectiveWidth, MAX_VFOV));
|
|
336
|
+
// Re-clamp the current FOV under the new limiter.
|
|
337
|
+
view.setFov(Math.min(INITIAL_FOV, view.fov()));
|
|
338
|
+
logPanovideo("applyZoomLimit: complete", {
|
|
339
|
+
reason,
|
|
340
|
+
videoWidth: video.videoWidth,
|
|
341
|
+
videoHeight: video.videoHeight,
|
|
342
|
+
maxZoom: maxZoomRef.current,
|
|
343
|
+
effectiveWidth,
|
|
344
|
+
fovDeg: (view.fov() * 180) / Math.PI,
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
React.useEffect(() => {
|
|
349
|
+
logPanovideo("fullscreen effect: start");
|
|
350
|
+
const onFullscreenChange = () => {
|
|
351
|
+
const fullscreen = document.fullscreenElement === playerRef.current;
|
|
352
|
+
logPanovideo("fullscreen event: changed", { fullscreen });
|
|
353
|
+
setIsFullscreen(fullscreen);
|
|
354
|
+
};
|
|
355
|
+
document.addEventListener("fullscreenchange", onFullscreenChange);
|
|
356
|
+
logPanovideo("fullscreen effect: complete");
|
|
357
|
+
return () => {
|
|
358
|
+
logPanovideo("fullscreen effect cleanup: start");
|
|
359
|
+
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
|
360
|
+
logPanovideo("fullscreen effect cleanup: complete");
|
|
361
|
+
};
|
|
362
|
+
}, []);
|
|
61
363
|
|
|
62
|
-
// (Re)builds the Marzipano viewer whenever the video URL (props.value) changes.
|
|
63
364
|
React.useEffect(() => {
|
|
64
|
-
|
|
365
|
+
const container = containerRef.current;
|
|
366
|
+
if (!container) {
|
|
65
367
|
return undefined;
|
|
66
368
|
}
|
|
67
369
|
|
|
68
|
-
|
|
370
|
+
const resizeViewer = (reason: string) => {
|
|
371
|
+
const viewer = viewerRef.current;
|
|
372
|
+
if (!viewer) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
logPanovideo("resize: updating Marzipano size", {
|
|
376
|
+
reason,
|
|
377
|
+
fullscreen: isFullscreen,
|
|
378
|
+
width: container.clientWidth,
|
|
379
|
+
height: container.clientHeight,
|
|
380
|
+
});
|
|
381
|
+
viewer.updateSize();
|
|
382
|
+
};
|
|
69
383
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const videoEl = document.createElement("video");
|
|
74
|
-
videoEl.crossOrigin = "anonymous";
|
|
75
|
-
videoEl.loop = true;
|
|
76
|
-
videoEl.muted = true; // required for autoplay in most browsers
|
|
77
|
-
videoEl.playsInline = true;
|
|
78
|
-
videoEl.src = value;
|
|
79
|
-
videoElRef.current = videoEl;
|
|
384
|
+
// Fires whenever the container's real size changes, including the end of the fullscreen transition.
|
|
385
|
+
const observer = new ResizeObserver(() => resizeViewer("ResizeObserver"));
|
|
386
|
+
observer.observe(container);
|
|
80
387
|
|
|
81
|
-
|
|
82
|
-
let
|
|
388
|
+
// Backup: re-measure over two frames after the fullscreen state flips.
|
|
389
|
+
let frame2 = 0;
|
|
390
|
+
const frame1 = requestAnimationFrame(() => {
|
|
391
|
+
resizeViewer("fullscreen frame 1");
|
|
392
|
+
frame2 = requestAnimationFrame(() => resizeViewer("fullscreen frame 2"));
|
|
393
|
+
});
|
|
83
394
|
|
|
84
|
-
|
|
85
|
-
|
|
395
|
+
return () => {
|
|
396
|
+
observer.disconnect();
|
|
397
|
+
cancelAnimationFrame(frame1);
|
|
398
|
+
cancelAnimationFrame(frame2);
|
|
399
|
+
};
|
|
400
|
+
}, [isFullscreen]);
|
|
401
|
+
|
|
402
|
+
React.useEffect(() => {
|
|
403
|
+
logPanovideo("wheel effect: start");
|
|
404
|
+
const player = playerRef.current;
|
|
405
|
+
if (!player) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
const onWheel = (event: WheelEvent) => {
|
|
409
|
+
const view = viewRef.current;
|
|
410
|
+
if (!view || event.deltaY === 0) {
|
|
86
411
|
return;
|
|
87
412
|
}
|
|
413
|
+
const deltaMultiplier = event.deltaMode === 1 ? 20 : event.deltaMode === 2 ? player.clientHeight : 1;
|
|
414
|
+
event.preventDefault();
|
|
415
|
+
const previousFov = view.fov();
|
|
416
|
+
// Proportional zoom: the same wheel travel feels the same at any FOV.
|
|
417
|
+
view.setFov(previousFov * Math.exp(event.deltaY * deltaMultiplier * WHEEL_SENSITIVITY));
|
|
418
|
+
logPanovideo("wheel: zoom", { deltaY: event.deltaY, previousFov, nextFov: view.fov() });
|
|
419
|
+
};
|
|
420
|
+
player.addEventListener("wheel", onWheel, { passive: false });
|
|
421
|
+
logPanovideo("wheel effect: complete");
|
|
422
|
+
return () => {
|
|
423
|
+
logPanovideo("wheel effect cleanup");
|
|
424
|
+
player.removeEventListener("wheel", onWheel);
|
|
425
|
+
};
|
|
426
|
+
}, []);
|
|
88
427
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
videoEl
|
|
119
|
-
.play()
|
|
120
|
-
.then(() => setIsPlaying(true))
|
|
121
|
-
.catch(() => {
|
|
122
|
-
// Autoplay was blocked - user needs to tap play.
|
|
123
|
-
setIsPlaying(false);
|
|
124
|
-
});
|
|
125
|
-
} catch (e) {
|
|
126
|
-
setError(
|
|
127
|
-
e instanceof Error ? e.message : "Failed to initialize panoramic video."
|
|
128
|
-
);
|
|
129
|
-
}
|
|
428
|
+
/* ---- 1. create the Marzipano viewer and view once ---- */
|
|
429
|
+
React.useEffect(() => {
|
|
430
|
+
logPanovideo("viewer effect: start");
|
|
431
|
+
const container = containerRef.current;
|
|
432
|
+
if (!container) {
|
|
433
|
+
logPanovideo("viewer effect: skipped; no container");
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
logPanovideo("viewer effect: creating Marzipano viewer");
|
|
438
|
+
const viewer = new M.Viewer(container, { controls: { scrollZoom: false } });
|
|
439
|
+
logPanovideo("viewer effect: viewer created");
|
|
440
|
+
|
|
441
|
+
// Permissive limiter for now; the real one is applied once the video
|
|
442
|
+
// reports its dimensions (see applyZoomLimit).
|
|
443
|
+
const limiter = M.RectilinearView.limit.traditional(PLACEHOLDER_MAX_RESOLUTION, MAX_VFOV);
|
|
444
|
+
const view = new M.RectilinearView({ yaw: Math.PI, fov: INITIAL_FOV }, limiter);
|
|
445
|
+
logPanovideo("viewer effect: view created", { fovDeg: (view.fov() * 180) / Math.PI });
|
|
446
|
+
|
|
447
|
+
viewerRef.current = viewer;
|
|
448
|
+
viewRef.current = view;
|
|
449
|
+
logPanovideo("viewer effect: complete");
|
|
450
|
+
|
|
451
|
+
return () => {
|
|
452
|
+
logPanovideo("viewer effect cleanup: start");
|
|
453
|
+
viewer.destroy();
|
|
454
|
+
viewerRef.current = null;
|
|
455
|
+
viewRef.current = null;
|
|
456
|
+
logPanovideo("viewer effect cleanup: complete");
|
|
130
457
|
};
|
|
458
|
+
}, []);
|
|
131
459
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
460
|
+
/* ---- 2. create the video and scene; rebuilt whenever the source URL changes ---- */
|
|
461
|
+
React.useEffect(() => {
|
|
462
|
+
logPanovideo("video effect: start", { sourceUrl });
|
|
463
|
+
const viewer = viewerRef.current;
|
|
464
|
+
const view = viewRef.current;
|
|
465
|
+
if (!viewer || !view || !sourceUrl) {
|
|
466
|
+
logPanovideo("video effect: skipped", {
|
|
467
|
+
hasViewer: Boolean(viewer),
|
|
468
|
+
hasView: Boolean(view),
|
|
469
|
+
sourceUrl,
|
|
470
|
+
});
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
setError(undefined);
|
|
475
|
+
setCurrentTime(0);
|
|
476
|
+
setDuration(0);
|
|
477
|
+
setLoading(true);
|
|
478
|
+
setLoadPercent(0);
|
|
479
|
+
|
|
480
|
+
logPanovideo("video effect: creating video", { sourceUrl });
|
|
481
|
+
const video = createVideo(sourceUrl, videoRef.current?.muted ?? true);
|
|
482
|
+
videoRef.current = video;
|
|
483
|
+
setMuted(video.muted);
|
|
484
|
+
video.defaultPlaybackRate = playbackRateRef.current;
|
|
485
|
+
video.playbackRate = playbackRateRef.current;
|
|
486
|
+
|
|
487
|
+
const onPlay = () => {
|
|
488
|
+
logPanovideo("video event: play", { src: video.currentSrc || video.src });
|
|
489
|
+
setPlaying(true);
|
|
490
|
+
};
|
|
491
|
+
const onPause = () => {
|
|
492
|
+
logPanovideo("video event: pause", { src: video.currentSrc || video.src });
|
|
493
|
+
setPlaying(false);
|
|
494
|
+
};
|
|
495
|
+
const onError = () => {
|
|
496
|
+
setLoading(false);
|
|
497
|
+
logPanovideo("video event: error", { src: video.currentSrc || video.src, error: video.error });
|
|
498
|
+
setError(`Unable to load ${video.src}`);
|
|
136
499
|
};
|
|
137
500
|
|
|
138
|
-
|
|
139
|
-
|
|
501
|
+
const onWaiting = () => {
|
|
502
|
+
logPanovideo("video event: waiting (buffering)");
|
|
503
|
+
setLoading(true);
|
|
504
|
+
};
|
|
505
|
+
const onReady = () => {
|
|
506
|
+
logPanovideo("video event: ready to play", { readyState: video.readyState });
|
|
507
|
+
setLoading(false);
|
|
508
|
+
};
|
|
509
|
+
const onProgress = () => {
|
|
510
|
+
if (!Number.isFinite(video.duration) || video.duration <= 0 || video.buffered.length === 0) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
|
514
|
+
setLoadPercent(Math.min(100, Math.round((bufferedEnd / video.duration) * 100)));
|
|
515
|
+
};
|
|
516
|
+
const updateTimeline = () => {
|
|
517
|
+
setCurrentTime(video.currentTime);
|
|
518
|
+
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
|
|
519
|
+
};
|
|
520
|
+
const onLoadedMetadata = () => {
|
|
521
|
+
logPanovideo("video event: loadedmetadata", {
|
|
522
|
+
videoWidth: video.videoWidth,
|
|
523
|
+
videoHeight: video.videoHeight,
|
|
524
|
+
duration: video.duration,
|
|
525
|
+
});
|
|
526
|
+
updateTimeline();
|
|
527
|
+
applyZoomLimit("loadedmetadata");
|
|
528
|
+
try {
|
|
529
|
+
raiseEventRef.current("loaded" as any, sourceUrl);
|
|
530
|
+
} catch (raiseError) {
|
|
531
|
+
logPanovideo("video event: raiseEvent(loaded) failed", raiseError);
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
video.addEventListener("play", onPlay);
|
|
535
|
+
video.addEventListener("pause", onPause);
|
|
536
|
+
video.addEventListener("error", onError);
|
|
537
|
+
video.addEventListener("timeupdate", updateTimeline);
|
|
538
|
+
video.addEventListener("durationchange", updateTimeline);
|
|
539
|
+
video.addEventListener("loadedmetadata", onLoadedMetadata);
|
|
540
|
+
video.addEventListener("seeked", updateTimeline);
|
|
541
|
+
video.addEventListener("waiting", onWaiting);
|
|
542
|
+
video.addEventListener("canplay", onReady);
|
|
543
|
+
video.addEventListener("playing", onReady);
|
|
544
|
+
video.addEventListener("progress", onProgress);
|
|
545
|
+
|
|
546
|
+
const asset = new VideoAsset(video);
|
|
547
|
+
logPanovideo("video effect: asset created");
|
|
548
|
+
const source = new M.SingleAssetSource(asset);
|
|
549
|
+
logPanovideo("video effect: source created");
|
|
550
|
+
const geometry = new M.EquirectGeometry([{ width: 1 }]);
|
|
551
|
+
logPanovideo("video effect: geometry created");
|
|
552
|
+
|
|
553
|
+
logPanovideo("video effect: creating scene");
|
|
554
|
+
// The view is reused so camera direction and zoom survive a source swap.
|
|
555
|
+
const scene = viewer.createScene({ source, geometry, view, pinFirstLevel: true });
|
|
556
|
+
logPanovideo("video effect: switching to scene");
|
|
557
|
+
scene.switchTo();
|
|
558
|
+
assetRef.current = asset;
|
|
559
|
+
|
|
560
|
+
if (autoplayRef.current) {
|
|
561
|
+
logPanovideo("video effect: attempting autoplay");
|
|
562
|
+
void video.play().then(
|
|
563
|
+
() => logPanovideo("video effect: autoplay started"),
|
|
564
|
+
(playError: unknown) => logPanovideo("video effect: autoplay rejected", playError),
|
|
565
|
+
);
|
|
566
|
+
} else {
|
|
567
|
+
logPanovideo("video effect: autoplay disabled");
|
|
568
|
+
}
|
|
569
|
+
logPanovideo("video effect: complete");
|
|
140
570
|
|
|
141
571
|
return () => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
572
|
+
logPanovideo("video effect cleanup: start");
|
|
573
|
+
video.removeEventListener("play", onPlay);
|
|
574
|
+
video.removeEventListener("pause", onPause);
|
|
575
|
+
video.removeEventListener("error", onError);
|
|
576
|
+
video.removeEventListener("timeupdate", updateTimeline);
|
|
577
|
+
video.removeEventListener("durationchange", updateTimeline);
|
|
578
|
+
video.removeEventListener("loadedmetadata", onLoadedMetadata);
|
|
579
|
+
video.removeEventListener("seeked", updateTimeline);
|
|
580
|
+
video.removeEventListener("waiting", onWaiting);
|
|
581
|
+
video.removeEventListener("canplay", onReady);
|
|
582
|
+
video.removeEventListener("playing", onReady);
|
|
583
|
+
video.removeEventListener("progress", onProgress);
|
|
584
|
+
asset.destroy();
|
|
585
|
+
// On unmount the viewer effect cleanup has already destroyed the viewer.
|
|
586
|
+
if (viewerRef.current === viewer) {
|
|
150
587
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// no-op
|
|
588
|
+
viewer.destroyScene(scene);
|
|
589
|
+
} catch (sceneError) {
|
|
590
|
+
logPanovideo("video effect cleanup: destroyScene failed", sceneError);
|
|
155
591
|
}
|
|
156
|
-
viewerRef.current = null;
|
|
157
592
|
}
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
|
|
593
|
+
disposeVideo(video);
|
|
594
|
+
if (videoRef.current === video) {
|
|
595
|
+
videoRef.current = null;
|
|
596
|
+
}
|
|
597
|
+
if (assetRef.current === asset) {
|
|
598
|
+
assetRef.current = null;
|
|
161
599
|
}
|
|
600
|
+
logPanovideo("video effect cleanup: complete");
|
|
162
601
|
};
|
|
163
|
-
|
|
602
|
+
// Rebuilt only when the source URL changes. Other props are read via refs.
|
|
603
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
604
|
+
}, [sourceUrl]);
|
|
164
605
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
606
|
+
/* ---- re-apply the zoom limit when `maxZoom` changes at runtime ---- */
|
|
607
|
+
React.useEffect(() => {
|
|
608
|
+
logPanovideo("maxZoom effect", { maxZoom });
|
|
609
|
+
applyZoomLimit("maxZoom changed");
|
|
610
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
611
|
+
}, [maxZoom]);
|
|
612
|
+
|
|
613
|
+
/* ---- controls ---- */
|
|
614
|
+
const togglePlay = () => {
|
|
615
|
+
logPanovideo("togglePlay: start");
|
|
616
|
+
const v = videoRef.current;
|
|
617
|
+
if (!v) {
|
|
618
|
+
logPanovideo("togglePlay: skipped; no video");
|
|
168
619
|
return;
|
|
169
620
|
}
|
|
170
|
-
if (
|
|
171
|
-
|
|
621
|
+
if (v.paused) {
|
|
622
|
+
logPanovideo("togglePlay: requesting playback", { src: v.currentSrc || v.src });
|
|
623
|
+
void v.play().then(
|
|
624
|
+
() => logPanovideo("togglePlay: playback started"),
|
|
625
|
+
(playError: unknown) => logPanovideo("togglePlay: playback rejected", playError),
|
|
626
|
+
);
|
|
172
627
|
} else {
|
|
173
|
-
|
|
174
|
-
|
|
628
|
+
logPanovideo("togglePlay: pausing playback", { currentTime: v.currentTime });
|
|
629
|
+
v.pause();
|
|
175
630
|
}
|
|
631
|
+
logPanovideo("togglePlay: complete");
|
|
176
632
|
};
|
|
177
633
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
634
|
+
const toggleMute = () => {
|
|
635
|
+
logPanovideo("toggleMute: start");
|
|
636
|
+
const v = videoRef.current;
|
|
637
|
+
if (!v) {
|
|
638
|
+
logPanovideo("toggleMute: skipped; no video");
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
v.muted = !v.muted;
|
|
642
|
+
setMuted(v.muted);
|
|
643
|
+
logPanovideo("toggleMute: complete", { muted: v.muted });
|
|
644
|
+
};
|
|
187
645
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
646
|
+
const changeSpeed = (rate: number) => {
|
|
647
|
+
logPanovideo("changeSpeed", { rate });
|
|
648
|
+
playbackRateRef.current = rate;
|
|
649
|
+
setPlaybackRate(rate);
|
|
650
|
+
const v = videoRef.current;
|
|
651
|
+
if (v) {
|
|
652
|
+
v.defaultPlaybackRate = rate;
|
|
653
|
+
v.playbackRate = rate;
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const adjustZoom = (direction: "in" | "out") => {
|
|
658
|
+
const view = viewRef.current;
|
|
659
|
+
if (!view) {
|
|
660
|
+
logPanovideo("adjustZoom: skipped; no view", { direction });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const previousFov = view.fov();
|
|
664
|
+
// Proportional step; setFov passes through the limiter, so min/max are enforced.
|
|
665
|
+
view.setFov(previousFov * (direction === "in" ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR));
|
|
666
|
+
logPanovideo("adjustZoom: complete", {
|
|
667
|
+
direction,
|
|
668
|
+
previousFov,
|
|
669
|
+
nextFov: view.fov(),
|
|
670
|
+
nextFovDeg: (view.fov() * 180) / Math.PI,
|
|
671
|
+
});
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const seekTo = (time: number) => {
|
|
675
|
+
const video = videoRef.current;
|
|
676
|
+
if (!video || !Number.isFinite(video.duration)) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const nextTime = Math.max(0, Math.min(time, video.duration));
|
|
680
|
+
video.currentTime = nextTime;
|
|
681
|
+
setCurrentTime(nextTime);
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const toggleFullscreen = async () => {
|
|
685
|
+
logPanovideo("toggleFullscreen: start", { isFullscreen });
|
|
686
|
+
const player = playerRef.current;
|
|
687
|
+
if (!player) {
|
|
688
|
+
logPanovideo("toggleFullscreen: skipped; no player element");
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
if (document.fullscreenElement === player) {
|
|
693
|
+
await document.exitFullscreen();
|
|
694
|
+
} else {
|
|
695
|
+
await player.requestFullscreen();
|
|
696
|
+
}
|
|
697
|
+
logPanovideo("toggleFullscreen: request complete");
|
|
698
|
+
} catch (fullscreenError) {
|
|
699
|
+
logPanovideo("toggleFullscreen: request failed", fullscreenError);
|
|
700
|
+
setError("Fullscreen is unavailable in this browser or context.");
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const handlePlayerKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
705
|
+
if (event.repeat) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const target = event.target;
|
|
709
|
+
const isTextEntry =
|
|
710
|
+
target instanceof HTMLElement &&
|
|
711
|
+
(target.isContentEditable || target.closest("input, select, textarea, [contenteditable='true']"));
|
|
712
|
+
if (isTextEntry) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (event.key === "+" || event.key === "=") {
|
|
716
|
+
event.preventDefault();
|
|
717
|
+
adjustZoom("in");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (event.key === "-" || event.key === "_") {
|
|
721
|
+
event.preventDefault();
|
|
722
|
+
adjustZoom("out");
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (event.code !== "Space") {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (target instanceof HTMLElement && target.closest("button")) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
event.preventDefault();
|
|
732
|
+
logPanovideo("keyboard: Space pressed; toggling playback");
|
|
733
|
+
togglePlay();
|
|
734
|
+
};
|
|
191
735
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
736
|
+
const playerStyle: React.CSSProperties = isFullscreen
|
|
737
|
+
? {
|
|
738
|
+
position: "fixed",
|
|
739
|
+
inset: 0,
|
|
740
|
+
width: "100vw",
|
|
741
|
+
height: "100vh",
|
|
742
|
+
background: "#000",
|
|
743
|
+
zIndex: 9999,
|
|
744
|
+
}
|
|
745
|
+
: {
|
|
746
|
+
position: "relative",
|
|
747
|
+
width: "100%",
|
|
748
|
+
aspectRatio: "2 / 1",
|
|
749
|
+
background: "#000",
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
const element = (
|
|
753
|
+
<div
|
|
754
|
+
ref={playerRef}
|
|
755
|
+
style={playerStyle}
|
|
756
|
+
role="region"
|
|
757
|
+
aria-label="Panoramic video player"
|
|
758
|
+
tabIndex={0}
|
|
759
|
+
onKeyDown={handlePlayerKeyDown}
|
|
760
|
+
>
|
|
761
|
+
<div ref={containerRef} style={{ position: "absolute", inset: 0 }} />
|
|
762
|
+
<style>{`@keyframes panovideo-spin { to { transform: rotate(360deg); } }`}</style>
|
|
763
|
+
|
|
764
|
+
{loading && !error && (
|
|
765
|
+
<div style={loadingOverlayStyle} role="status" aria-live="polite">
|
|
766
|
+
<div style={spinnerStyle} />
|
|
767
|
+
<span>Loading video{loadPercent > 0 ? `… ${loadPercent}%` : "…"}</span>
|
|
768
|
+
</div>
|
|
769
|
+
)}
|
|
770
|
+
<div style={zoomControlsStyle}>
|
|
771
|
+
<button type="button" style={zoomBtnStyle} onClick={() => adjustZoom("in")} title="Zoom in" aria-label="Zoom in">
|
|
772
|
+
+
|
|
773
|
+
</button>
|
|
774
|
+
<button type="button" style={zoomBtnStyle} onClick={() => adjustZoom("out")} title="Zoom out" aria-label="Zoom out">
|
|
775
|
+
−
|
|
776
|
+
</button>
|
|
777
|
+
</div>
|
|
778
|
+
|
|
779
|
+
<div style={barStyle}>
|
|
780
|
+
<button type="button" style={btnStyle} onClick={togglePlay} title="Play / pause">
|
|
781
|
+
{playing ? "⏸" : "▶"}
|
|
782
|
+
</button>
|
|
783
|
+
{/* <button type="button" style={btnStyle} onClick={toggleMute} title="Sound on / off">
|
|
784
|
+
{muted ? "🔇" : "🔊"}
|
|
785
|
+
</button> */}
|
|
786
|
+
<select
|
|
787
|
+
style={speedStyle}
|
|
788
|
+
value={playbackRate}
|
|
789
|
+
onChange={(event) => changeSpeed(Number(event.currentTarget.value))}
|
|
790
|
+
title="Playback speed"
|
|
791
|
+
aria-label="Playback speed"
|
|
792
|
+
>
|
|
793
|
+
{SPEED_OPTIONS.map((rate) => (
|
|
794
|
+
<option key={rate} value={rate} style={{ color: "#000" }}>
|
|
795
|
+
{rate}x
|
|
796
|
+
</option>
|
|
797
|
+
))}
|
|
798
|
+
</select>
|
|
799
|
+
<span style={timeStyle} aria-live="off">
|
|
800
|
+
{formatTime(currentTime)} / {formatTime(duration)}
|
|
801
|
+
</span>
|
|
802
|
+
<input
|
|
803
|
+
type="range"
|
|
804
|
+
min={0}
|
|
805
|
+
max={duration}
|
|
806
|
+
step={0.1}
|
|
807
|
+
value={Math.min(currentTime, duration)}
|
|
808
|
+
disabled={duration <= 0}
|
|
809
|
+
onChange={(event) => seekTo(Number(event.currentTarget.value))}
|
|
810
|
+
style={timelineStyle}
|
|
811
|
+
aria-label="Video progress"
|
|
812
|
+
aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}
|
|
813
|
+
/>
|
|
814
|
+
<button
|
|
815
|
+
type="button"
|
|
816
|
+
style={btnStyle}
|
|
817
|
+
onClick={toggleFullscreen}
|
|
818
|
+
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
819
|
+
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
820
|
+
>
|
|
821
|
+
{isFullscreen ? "⤢" : "⛶"}
|
|
822
|
+
</button>
|
|
823
|
+
</div>
|
|
824
|
+
|
|
825
|
+
{error && (
|
|
826
|
+
<div style={{ ...barStyle, top: 8, bottom: "auto", background: "rgba(160,0,0,0.8)" }}>
|
|
827
|
+
{error}
|
|
213
828
|
</div>
|
|
214
829
|
)}
|
|
215
830
|
</div>
|
|
216
831
|
);
|
|
832
|
+
logPanovideo("component render: JSX tree created", { playing, muted, isFullscreen, error });
|
|
833
|
+
return element;
|
|
217
834
|
}
|
|
218
835
|
|
|
219
836
|
const PanovideoPlayerElementRegistration: FormElementRegistration<PanovideoPlayerProps> = {
|
|
220
837
|
component: PanovideoPlayer,
|
|
221
|
-
getInitialProperties: () =>
|
|
838
|
+
getInitialProperties: () => {
|
|
839
|
+
logPanovideo("getInitialProperties: start");
|
|
840
|
+
const properties = {
|
|
841
|
+
value: "",
|
|
842
|
+
resolutions: DEFAULT_RESOLUTIONS,
|
|
843
|
+
autoplay: true,
|
|
844
|
+
maxZoom: 3,
|
|
845
|
+
};
|
|
846
|
+
logPanovideo("getInitialProperties: complete", properties);
|
|
847
|
+
return properties;
|
|
848
|
+
},
|
|
222
849
|
id: "PanovideoPlayer",
|
|
223
850
|
};
|
|
224
851
|
|
|
225
|
-
export default PanovideoPlayerElementRegistration;
|
|
852
|
+
export default PanovideoPlayerElementRegistration;
|