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.
@@ -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
- class VideoAsset extends (Marzipano as any).util.Emitter {
19
- private _element: HTMLVideoElement;
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
- constructor(videoElement: HTMLVideoElement) {
22
- super();
23
- this._element = videoElement;
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
- element(): HTMLVideoElement {
31
- return this._element;
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._element.videoWidth;
77
+ return this.el.videoWidth || 1;
36
78
  }
37
79
 
38
80
  height(): number {
39
- return this._element.videoHeight;
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 { setValue, value } = props;
55
-
56
- const containerRef = React.useRef<HTMLDivElement | null>(null);
57
- const videoElRef = React.useRef<HTMLVideoElement | null>(null);
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 [isPlaying, setIsPlaying] = React.useState(false);
60
- const [error, setError] = React.useState<string | null>(null);
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
- if (!value || !containerRef.current) {
365
+ const container = containerRef.current;
366
+ if (!container) {
65
367
  return undefined;
66
368
  }
67
369
 
68
- setError(null);
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
- // Create a hidden video element to drive the panorama. We don't
71
- // attach it to the DOM directly - Marzipano reads frames from it
72
- // and draws them into its own WebGL canvas.
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
- let viewer: any;
82
- let cancelled = false;
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
- const onLoadedMetadata = () => {
85
- if (cancelled || !containerRef.current) {
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
- try {
90
- viewer = new (Marzipano as any).Viewer(containerRef.current);
91
- viewerRef.current = viewer;
92
-
93
- const asset = new VideoAsset(videoEl);
94
- const source = (Marzipano as any).SingleAssetSource.fromAsset(asset);
95
-
96
- const geometry = new (Marzipano as any).EquirectGeometry([
97
- { width: videoEl.videoWidth || 1920 },
98
- ]);
99
-
100
- const limiter = (Marzipano as any).RectilinearView.limit.traditional(
101
- 1024,
102
- (100 * Math.PI) / 180
103
- );
104
- const view = new (Marzipano as any).RectilinearView(
105
- { yaw: 0, pitch: 0, fov: Math.PI / 2 },
106
- limiter
107
- );
108
-
109
- const scene = viewer.createScene({
110
- source,
111
- geometry,
112
- view,
113
- pinFirstLevel: true,
114
- });
115
-
116
- scene.switchTo();
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
- const onVideoError = () => {
133
- if (!cancelled) {
134
- setError("Unable to load video from the provided URL.");
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
- videoEl.addEventListener("loadedmetadata", onLoadedMetadata);
139
- videoEl.addEventListener("error", onVideoError);
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
- cancelled = true;
143
- videoEl.removeEventListener("loadedmetadata", onLoadedMetadata);
144
- videoEl.removeEventListener("error", onVideoError);
145
- videoEl.pause();
146
- videoEl.removeAttribute("src");
147
- videoEl.load();
148
-
149
- if (viewerRef.current) {
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
- // Not all Marzipano versions expose destroy(); guard defensively.
152
- viewerRef.current.destroy?.();
153
- } catch {
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 (containerRef.current) {
160
- containerRef.current.innerHTML = "";
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
- }, [value]);
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
- const togglePlayback = () => {
166
- const videoEl = videoElRef.current;
167
- if (!videoEl) {
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 (videoEl.paused) {
171
- videoEl.play().then(() => setIsPlaying(true));
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
- videoEl.pause();
174
- setIsPlaying(false);
628
+ logPanovideo("togglePlay: pausing playback", { currentTime: v.currentTime });
629
+ v.pause();
175
630
  }
631
+ logPanovideo("togglePlay: complete");
176
632
  };
177
633
 
178
- return (
179
- <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
180
- <input
181
- type="text"
182
- placeholder="Enter 360° equirectangular video URL"
183
- onChange={(event) => setValue(event.currentTarget.value)}
184
- value={value ?? ""}
185
- style={{ width: "100%" }}
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
- {error && (
189
- <div style={{ color: "red", fontSize: 12 }}>{error}</div>
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
- {value && (
193
- <div style={{ position: "relative" }}>
194
- <div
195
- ref={containerRef}
196
- style={{
197
- width: "100%",
198
- height: 400,
199
- background: "#000",
200
- }}
201
- />
202
- <button
203
- type="button"
204
- onClick={togglePlayback}
205
- style={{
206
- position: "absolute",
207
- bottom: 8,
208
- left: 8,
209
- }}
210
- >
211
- {isPlaying ? "Pause" : "Play"}
212
- </button>
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: () => ({ value: "" }),
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;