storysplat-viewer 2.4.9 → 2.5.1

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.
@@ -0,0 +1,1646 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>StorySplat Viewer API Controls Demo</title>
7
+ <style>
8
+ /* ============================================
9
+ StorySplat Dark Theme Styling
10
+ ============================================ */
11
+
12
+ :root {
13
+ --bg-primary: #0d0d1a;
14
+ --bg-secondary: #1a1a2e;
15
+ --bg-tertiary: #252540;
16
+ --accent-primary: #6366f1;
17
+ --accent-secondary: #818cf8;
18
+ --accent-success: #22c55e;
19
+ --accent-warning: #f59e0b;
20
+ --accent-error: #ef4444;
21
+ --text-primary: #ffffff;
22
+ --text-secondary: #a1a1aa;
23
+ --text-muted: #71717a;
24
+ --border-color: #3f3f5a;
25
+ --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
26
+ }
27
+
28
+ * {
29
+ margin: 0;
30
+ padding: 0;
31
+ box-sizing: border-box;
32
+ }
33
+
34
+ body {
35
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
36
+ background: var(--bg-primary);
37
+ color: var(--text-primary);
38
+ line-height: 1.6;
39
+ min-height: 100vh;
40
+ }
41
+
42
+ /* Header */
43
+ header {
44
+ background: var(--bg-secondary);
45
+ border-bottom: 1px solid var(--border-color);
46
+ padding: 1rem 2rem;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: space-between;
50
+ flex-wrap: wrap;
51
+ gap: 1rem;
52
+ }
53
+
54
+ .logo {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 0.75rem;
58
+ }
59
+
60
+ .logo-icon {
61
+ width: 40px;
62
+ height: 40px;
63
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
64
+ border-radius: 8px;
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ font-weight: bold;
69
+ font-size: 1.2rem;
70
+ }
71
+
72
+ .logo h1 {
73
+ font-size: 1.25rem;
74
+ font-weight: 600;
75
+ }
76
+
77
+ .scene-id-input {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 0.5rem;
81
+ }
82
+
83
+ .scene-id-input label {
84
+ color: var(--text-secondary);
85
+ font-size: 0.875rem;
86
+ }
87
+
88
+ .scene-id-input input {
89
+ background: var(--bg-tertiary);
90
+ border: 1px solid var(--border-color);
91
+ border-radius: 6px;
92
+ padding: 0.5rem 0.75rem;
93
+ color: var(--text-primary);
94
+ font-size: 0.875rem;
95
+ width: 200px;
96
+ }
97
+
98
+ .scene-id-input input:focus {
99
+ outline: none;
100
+ border-color: var(--accent-primary);
101
+ }
102
+
103
+ .scene-id-input button {
104
+ background: var(--accent-primary);
105
+ color: white;
106
+ border: none;
107
+ border-radius: 6px;
108
+ padding: 0.5rem 1rem;
109
+ font-size: 0.875rem;
110
+ cursor: pointer;
111
+ transition: background 0.2s;
112
+ }
113
+
114
+ .scene-id-input button:hover {
115
+ background: var(--accent-secondary);
116
+ }
117
+
118
+ /* Main Layout */
119
+ main {
120
+ display: grid;
121
+ grid-template-columns: 1fr 400px;
122
+ gap: 1rem;
123
+ padding: 1rem;
124
+ min-height: calc(100vh - 80px);
125
+ }
126
+
127
+ .left-column {
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: 1rem;
131
+ overflow-y: auto;
132
+ }
133
+
134
+ @media (max-width: 1024px) {
135
+ main {
136
+ grid-template-columns: 1fr;
137
+ height: auto;
138
+ }
139
+ }
140
+
141
+ /* Viewer Container */
142
+ #viewer-container {
143
+ background: #000;
144
+ border-radius: 12px;
145
+ overflow: hidden;
146
+ position: relative;
147
+ min-height: 300px;
148
+ max-height: 50vh;
149
+ }
150
+
151
+ .viewer-status {
152
+ position: absolute;
153
+ top: 1rem;
154
+ left: 1rem;
155
+ background: rgba(0, 0, 0, 0.7);
156
+ padding: 0.5rem 1rem;
157
+ border-radius: 6px;
158
+ font-size: 0.875rem;
159
+ z-index: 10;
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 0.5rem;
163
+ }
164
+
165
+ .status-dot {
166
+ width: 8px;
167
+ height: 8px;
168
+ border-radius: 50%;
169
+ background: var(--accent-warning);
170
+ }
171
+
172
+ .status-dot.ready {
173
+ background: var(--accent-success);
174
+ }
175
+
176
+ .status-dot.error {
177
+ background: var(--accent-error);
178
+ }
179
+
180
+ /* Controls Sidebar */
181
+ .controls-sidebar {
182
+ display: flex;
183
+ flex-direction: column;
184
+ height: calc(100vh - 100px);
185
+ }
186
+
187
+ .controls-sidebar .control-panel {
188
+ flex: 1;
189
+ display: flex;
190
+ flex-direction: column;
191
+ min-height: 0;
192
+ }
193
+
194
+ .controls-sidebar .panel-content {
195
+ flex: 1;
196
+ overflow-y: auto;
197
+ }
198
+
199
+ /* Control Panel */
200
+ .control-panel {
201
+ background: var(--bg-secondary);
202
+ border: 1px solid var(--border-color);
203
+ border-radius: 12px;
204
+ overflow: hidden;
205
+ }
206
+
207
+ .panel-header {
208
+ background: var(--bg-tertiary);
209
+ padding: 0.5rem 0.75rem;
210
+ font-weight: 600;
211
+ font-size: 0.8rem;
212
+ border-bottom: 1px solid var(--border-color);
213
+ display: flex;
214
+ align-items: center;
215
+ gap: 0.5rem;
216
+ }
217
+
218
+ .panel-header svg {
219
+ width: 18px;
220
+ height: 18px;
221
+ opacity: 0.7;
222
+ }
223
+
224
+ .panel-content {
225
+ padding: 0.75rem;
226
+ }
227
+
228
+ .section-title {
229
+ font-size: 0.7rem;
230
+ text-transform: uppercase;
231
+ letter-spacing: 0.05em;
232
+ color: var(--accent-secondary);
233
+ margin-bottom: 0.35rem;
234
+ }
235
+
236
+ .section-divider {
237
+ border: none;
238
+ border-top: 1px solid var(--border-color);
239
+ margin: 0.6rem 0;
240
+ }
241
+
242
+ .code-comment {
243
+ font-size: 0.7rem;
244
+ margin-bottom: 0.35rem;
245
+ }
246
+
247
+ /* Button Styles */
248
+ .btn {
249
+ background: var(--bg-tertiary);
250
+ border: 1px solid var(--border-color);
251
+ border-radius: 5px;
252
+ padding: 0.35rem 0.75rem;
253
+ color: var(--text-primary);
254
+ font-size: 0.75rem;
255
+ cursor: pointer;
256
+ transition: all 0.2s;
257
+ display: inline-flex;
258
+ align-items: center;
259
+ justify-content: center;
260
+ gap: 0.35rem;
261
+ }
262
+
263
+ .btn:hover:not(:disabled) {
264
+ background: var(--border-color);
265
+ }
266
+
267
+ .btn:disabled {
268
+ opacity: 0.5;
269
+ cursor: not-allowed;
270
+ }
271
+
272
+ .btn-primary {
273
+ background: var(--accent-primary);
274
+ border-color: var(--accent-primary);
275
+ }
276
+
277
+ .btn-primary:hover:not(:disabled) {
278
+ background: var(--accent-secondary);
279
+ border-color: var(--accent-secondary);
280
+ }
281
+
282
+ .btn-success {
283
+ background: var(--accent-success);
284
+ border-color: var(--accent-success);
285
+ }
286
+
287
+ .btn-success:hover:not(:disabled) {
288
+ background: #16a34a;
289
+ border-color: #16a34a;
290
+ }
291
+
292
+ .btn-warning {
293
+ background: var(--accent-warning);
294
+ border-color: var(--accent-warning);
295
+ color: #000;
296
+ }
297
+
298
+ .btn-error {
299
+ background: var(--accent-error);
300
+ border-color: var(--accent-error);
301
+ }
302
+
303
+ /* Button Groups */
304
+ .btn-group {
305
+ display: flex;
306
+ gap: 0.35rem;
307
+ flex-wrap: wrap;
308
+ }
309
+
310
+ /* Input Styles */
311
+ .input-group {
312
+ display: flex;
313
+ flex-direction: column;
314
+ gap: 0.15rem;
315
+ }
316
+
317
+ .input-group label {
318
+ font-size: 0.65rem;
319
+ color: var(--text-secondary);
320
+ text-transform: uppercase;
321
+ letter-spacing: 0.05em;
322
+ }
323
+
324
+ .input-group input,
325
+ .input-group select {
326
+ background: var(--bg-tertiary);
327
+ border: 1px solid var(--border-color);
328
+ border-radius: 4px;
329
+ padding: 0.2rem 0.3rem;
330
+ color: var(--text-primary);
331
+ font-size: 0.7rem;
332
+ width: 100%;
333
+ min-width: 0;
334
+ }
335
+
336
+ .input-group input[type="number"] {
337
+ max-width: 60px;
338
+ }
339
+
340
+ .input-group input:focus,
341
+ .input-group select:focus {
342
+ outline: none;
343
+ border-color: var(--accent-primary);
344
+ }
345
+
346
+ .input-row {
347
+ display: grid;
348
+ grid-template-columns: repeat(3, 1fr);
349
+ gap: 0.5rem;
350
+ }
351
+
352
+ /* Navigation Controls */
353
+ .waypoint-status {
354
+ text-align: center;
355
+ padding: 0.75rem;
356
+ background: var(--bg-tertiary);
357
+ border-radius: 6px;
358
+ margin-bottom: 0.75rem;
359
+ font-size: 0.875rem;
360
+ }
361
+
362
+ .waypoint-status strong {
363
+ color: var(--accent-primary);
364
+ }
365
+
366
+ /* Playback Controls */
367
+ .playback-status {
368
+ display: flex;
369
+ align-items: center;
370
+ gap: 0.5rem;
371
+ padding: 0.5rem 0.75rem;
372
+ background: var(--bg-tertiary);
373
+ border-radius: 6px;
374
+ margin-top: 0.75rem;
375
+ font-size: 0.875rem;
376
+ }
377
+
378
+ .playback-indicator {
379
+ width: 10px;
380
+ height: 10px;
381
+ border-radius: 50%;
382
+ background: var(--text-muted);
383
+ }
384
+
385
+ .playback-indicator.playing {
386
+ background: var(--accent-success);
387
+ animation: pulse 1.5s infinite;
388
+ }
389
+
390
+ @keyframes pulse {
391
+ 0%, 100% { opacity: 1; }
392
+ 50% { opacity: 0.5; }
393
+ }
394
+
395
+ /* Camera Info */
396
+ .camera-info {
397
+ display: grid;
398
+ grid-template-columns: repeat(2, 1fr);
399
+ gap: 0.75rem;
400
+ margin-bottom: 1rem;
401
+ }
402
+
403
+ .camera-value {
404
+ background: var(--bg-tertiary);
405
+ padding: 0.5rem;
406
+ border-radius: 6px;
407
+ font-family: 'Monaco', 'Menlo', monospace;
408
+ font-size: 0.75rem;
409
+ }
410
+
411
+ .camera-value-label {
412
+ color: var(--text-muted);
413
+ font-size: 0.625rem;
414
+ text-transform: uppercase;
415
+ margin-bottom: 0.25rem;
416
+ }
417
+
418
+ /* Scene Info */
419
+ .scene-info-grid {
420
+ display: grid;
421
+ grid-template-columns: 1fr 1fr;
422
+ gap: 0.1rem 0.4rem;
423
+ }
424
+
425
+ .scene-info-item {
426
+ display: flex;
427
+ gap: 0.25rem;
428
+ align-items: baseline;
429
+ }
430
+
431
+ .scene-info-label {
432
+ color: var(--text-muted);
433
+ font-size: 0.6rem;
434
+ text-transform: uppercase;
435
+ white-space: nowrap;
436
+ }
437
+
438
+ .scene-info-label::after {
439
+ content: ':';
440
+ }
441
+
442
+ .scene-info-value {
443
+ font-size: 0.65rem;
444
+ word-break: break-word;
445
+ }
446
+
447
+ /* Event Log */
448
+ .event-log {
449
+ background: var(--bg-primary);
450
+ border: 1px solid var(--border-color);
451
+ border-radius: 6px;
452
+ height: 200px;
453
+ overflow-y: auto;
454
+ font-family: 'Monaco', 'Menlo', monospace;
455
+ font-size: 0.75rem;
456
+ }
457
+
458
+ .log-entry {
459
+ padding: 0.375rem 0.5rem;
460
+ border-bottom: 1px solid var(--border-color);
461
+ display: flex;
462
+ gap: 0.5rem;
463
+ }
464
+
465
+ .log-entry:last-child {
466
+ border-bottom: none;
467
+ }
468
+
469
+ .log-time {
470
+ color: var(--text-muted);
471
+ flex-shrink: 0;
472
+ }
473
+
474
+ .log-event {
475
+ color: var(--accent-primary);
476
+ flex-shrink: 0;
477
+ min-width: 100px;
478
+ }
479
+
480
+ .log-data {
481
+ color: var(--text-secondary);
482
+ word-break: break-all;
483
+ }
484
+
485
+ .log-entry.error .log-event {
486
+ color: var(--accent-error);
487
+ }
488
+
489
+ .log-entry.warning .log-event {
490
+ color: var(--accent-warning);
491
+ }
492
+
493
+ .log-entry.success .log-event {
494
+ color: var(--accent-success);
495
+ }
496
+
497
+ .clear-log {
498
+ margin-top: 0.5rem;
499
+ width: 100%;
500
+ }
501
+
502
+ /* Mode-dependent visibility */
503
+ .hidden-mode {
504
+ display: none !important;
505
+ }
506
+
507
+ .btn.mode-active {
508
+ background: var(--accent-primary);
509
+ border-color: var(--accent-primary);
510
+ }
511
+
512
+ /* Loading State */
513
+ .loading {
514
+ display: flex;
515
+ flex-direction: column;
516
+ align-items: center;
517
+ justify-content: center;
518
+ height: 100%;
519
+ gap: 1rem;
520
+ color: var(--text-secondary);
521
+ }
522
+
523
+ .spinner {
524
+ width: 40px;
525
+ height: 40px;
526
+ border: 3px solid var(--border-color);
527
+ border-top-color: var(--accent-primary);
528
+ border-radius: 50%;
529
+ animation: spin 1s linear infinite;
530
+ }
531
+
532
+ @keyframes spin {
533
+ to { transform: rotate(360deg); }
534
+ }
535
+
536
+ /* Code Comments */
537
+ .code-comment {
538
+ color: var(--text-muted);
539
+ font-size: 0.75rem;
540
+ font-style: italic;
541
+ margin-bottom: 0.5rem;
542
+ }
543
+ </style>
544
+ </head>
545
+ <body>
546
+ <!-- Header with scene ID input -->
547
+ <header>
548
+ <div class="logo">
549
+ <div class="logo-icon">SS</div>
550
+ <h1>StorySplat Viewer API Demo</h1>
551
+ </div>
552
+ <div class="scene-id-input">
553
+ <label for="scene-id">Scene ID:</label>
554
+ <input type="text" id="scene-id" value="f8dc96cf-6fc8-4d11-89bb-36c447c0d060" placeholder="Enter scene ID">
555
+ <button onclick="loadScene()">Load Scene</button>
556
+ </div>
557
+ </header>
558
+
559
+ <main>
560
+ <div class="left-column">
561
+ <!-- Viewer Container -->
562
+ <div id="viewer-container">
563
+ <div class="viewer-status">
564
+ <div class="status-dot" id="status-dot"></div>
565
+ <span id="status-text">Initializing...</span>
566
+ </div>
567
+ <div class="loading" id="viewer-loading">
568
+ <div class="spinner"></div>
569
+ <div>Loading viewer...</div>
570
+ </div>
571
+ </div>
572
+
573
+ <!-- Event Log -->
574
+ <div class="control-panel">
575
+ <div class="panel-header">Event Log</div>
576
+ <div class="panel-content">
577
+ <p class="code-comment">// viewer.on('ready' | 'loaded' | 'progress' | 'waypointChange' | ...)</p>
578
+ <div class="event-log" id="event-log"></div>
579
+ <button class="btn clear-log" onclick="clearLog()">Clear Log</button>
580
+ </div>
581
+ </div>
582
+ </div>
583
+
584
+ <!-- Controls Sidebar -->
585
+ <div class="controls-sidebar">
586
+ <div class="control-panel">
587
+ <div class="panel-header">API Controls</div>
588
+ <div class="panel-content" style="padding:0.6rem 0.75rem;">
589
+
590
+ <h3 class="section-title" style="margin:0 0 0.4rem">Scene</h3>
591
+ <div class="scene-info-grid" id="scene-info" style="font-size:0.65rem;line-height:1.3;display:grid;grid-template-columns:1fr 1fr;gap:0.15rem 0.5rem;margin-bottom:0">
592
+ <div class="scene-info-item"><span class="scene-info-label">Loading...</span></div>
593
+ </div>
594
+
595
+ <hr class="section-divider" style="margin:0.6rem 0">
596
+
597
+ <h3 class="section-title" style="margin:0 0 0.4rem">Mode</h3>
598
+ <div style="display:flex;align-items:center;gap:0.3rem;margin-bottom:0.35rem;flex-wrap:wrap;">
599
+ <span style="font-size:0.65rem;color:var(--text-secondary)">Camera Mode:</span>
600
+ <button class="btn btn-primary" id="btn-mode-tour" onclick="setMode('tour')">Tour</button>
601
+ <button class="btn" id="btn-mode-explore" onclick="setMode('explore')">Explore</button>
602
+ <span style="width:1px;height:16px;background:var(--border-color)" id="explore-sub-separator" class="hidden-mode"></span>
603
+ <button class="btn hidden-mode" id="btn-explore-orbit" onclick="setExploreMode('orbit')">Orbit</button>
604
+ <button class="btn hidden-mode" id="btn-explore-fly" onclick="setExploreMode('fly')">Fly</button>
605
+ <span id="mode-display" style="font-size:0.6rem;color:var(--text-muted);margin-left:auto;">tour</span>
606
+ </div>
607
+
608
+ <hr class="section-divider" style="margin:0.6rem 0">
609
+
610
+ <h3 class="section-title" style="margin:0 0 0.4rem">Navigation & Playback</h3>
611
+ <div style="display:flex;align-items:center;gap:0.3rem;margin-bottom:0.4rem;flex-wrap:wrap;">
612
+ <span style="font-size:0.65rem;color:var(--text-secondary)">WP <strong id="current-waypoint">-</strong>/<strong id="total-waypoints">-</strong></span>
613
+ <select id="waypoint-select" onchange="goToSelectedWaypoint()" style="flex:1;min-width:70px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;padding:0.15rem 0.25rem;color:var(--text-primary);font-size:0.65rem;">
614
+ <option value="">Select...</option>
615
+ </select>
616
+ </div>
617
+ <div class="btn-group" style="margin-bottom:0.25rem">
618
+ <button class="btn" id="btn-prev" onclick="prevWaypoint()" disabled>Prev</button>
619
+ <button class="btn" id="btn-next" onclick="nextWaypoint()" disabled>Next</button>
620
+ <span style="width:1px;height:16px;background:var(--border-color)"></span>
621
+ <button class="btn btn-success" id="btn-play" onclick="play()" disabled>Play</button>
622
+ <button class="btn btn-warning" id="btn-pause" onclick="pause()" disabled>Pause</button>
623
+ <button class="btn btn-error" id="btn-stop" onclick="stop()" disabled>Stop</button>
624
+ <div class="playback-indicator" id="playback-indicator"></div>
625
+ <span id="playback-status-text" style="font-size:0.6rem;color:var(--text-muted)">Stopped</span>
626
+ </div>
627
+ <div class="waypoint-status" id="waypoint-status" style="display:none"></div>
628
+ <div class="playback-status" style="display:none"></div>
629
+
630
+ <hr class="section-divider" style="margin:0.6rem 0">
631
+
632
+ <h3 class="section-title" style="margin:0 0 0.4rem">Camera</h3>
633
+ <div class="camera-info" style="margin-bottom:0.4rem;font-size:0.65rem;display:flex;gap:0.5rem">
634
+ <div class="camera-value"><span class="camera-value-label" style="font-size:0.6rem">Pos</span> <span id="camera-position">0, 0, 0</span></div>
635
+ <div class="camera-value"><span class="camera-value-label" style="font-size:0.6rem">Rot</span> <span id="camera-rotation">0, 0, 0</span></div>
636
+ </div>
637
+ <div style="display:flex;gap:0.25rem;align-items:end;margin-bottom:0.35rem">
638
+ <div class="input-group" style="flex:1"><label>X</label><input type="number" id="pos-x" step="0.1" value="0"></div>
639
+ <div class="input-group" style="flex:1"><label>Y</label><input type="number" id="pos-y" step="0.1" value="1.6"></div>
640
+ <div class="input-group" style="flex:1"><label>Z</label><input type="number" id="pos-z" step="0.1" value="5"></div>
641
+ <button class="btn btn-primary" onclick="applyPosition()" id="btn-apply-pos" disabled style="white-space:nowrap">Set Pos</button>
642
+ </div>
643
+ <div style="display:flex;gap:0.25rem;align-items:end;">
644
+ <div class="input-group" style="flex:1"><label>RX</label><input type="number" id="rot-x" step="1" value="0"></div>
645
+ <div class="input-group" style="flex:1"><label>RY</label><input type="number" id="rot-y" step="1" value="0"></div>
646
+ <div class="input-group" style="flex:1"><label>RZ</label><input type="number" id="rot-z" step="1" value="0"></div>
647
+ <button class="btn btn-primary" onclick="applyRotation()" id="btn-apply-rot" disabled style="white-space:nowrap">Set Rot</button>
648
+ </div>
649
+
650
+ <hr class="section-divider" style="margin:0.6rem 0">
651
+
652
+ <h3 class="section-title" style="margin:0 0 0.4rem">Progress</h3>
653
+ <div style="display:flex;gap:0.25rem;align-items:center;margin-bottom:0.35rem">
654
+ <input type="range" id="progress-slider" min="0" max="100" value="0" style="flex:1" oninput="updateProgressDisplay()">
655
+ <span id="progress-display" style="font-size:0.65rem;min-width:35px;text-align:right">0%</span>
656
+ <button class="btn btn-primary" onclick="applyProgress()" id="btn-apply-progress" disabled style="white-space:nowrap">Set</button>
657
+ </div>
658
+
659
+ <hr class="section-divider" style="margin:0.6rem 0">
660
+
661
+ <h3 class="section-title" style="margin:0 0 0.4rem">Audio</h3>
662
+ <div class="btn-group" style="margin-bottom:0.35rem">
663
+ <button class="btn" id="btn-mute" onclick="muteAll()" disabled>Mute All</button>
664
+ <button class="btn" id="btn-unmute" onclick="unmuteAll()" disabled>Unmute All</button>
665
+ <span id="mute-status" style="font-size:0.6rem;color:var(--text-muted);margin-left:auto">unmuted</span>
666
+ </div>
667
+
668
+ <hr class="section-divider" style="margin:0.6rem 0">
669
+
670
+ <h3 class="section-title" style="margin:0 0 0.4rem">Splat Swap</h3>
671
+ <div style="display:flex;gap:0.25rem;align-items:center;margin-bottom:0.35rem;flex-wrap:wrap">
672
+ <select id="splat-select" style="flex:1;min-width:100px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;padding:0.15rem 0.25rem;color:var(--text-primary);font-size:0.65rem;" disabled>
673
+ <option value="">No additional splats</option>
674
+ </select>
675
+ <button class="btn btn-primary" onclick="goToSelectedSplat()" id="btn-go-splat" disabled style="white-space:nowrap">Go</button>
676
+ <button class="btn" onclick="goToOriginalSplat()" id="btn-original-splat" disabled>Original</button>
677
+ </div>
678
+ <div id="current-splat" style="font-size:0.6rem;color:var(--text-muted)">Current: (loading...)</div>
679
+
680
+ <hr class="section-divider" style="margin:0.6rem 0">
681
+
682
+ <h3 class="section-title" style="margin:0 0 0.4rem">Hotspots</h3>
683
+ <div style="display:flex;gap:0.25rem;align-items:center;margin-bottom:0.35rem;flex-wrap:wrap">
684
+ <select id="hotspot-select" style="flex:1;min-width:100px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;padding:0.15rem 0.25rem;color:var(--text-primary);font-size:0.65rem;" disabled>
685
+ <option value="">No hotspots</option>
686
+ </select>
687
+ <button class="btn btn-primary" onclick="triggerSelectedHotspot()" id="btn-trigger-hotspot" disabled style="white-space:nowrap">Open</button>
688
+ <button class="btn" onclick="closeHotspot()" id="btn-close-hotspot" disabled>Close</button>
689
+ </div>
690
+
691
+ <hr class="section-divider" style="margin:0.6rem 0">
692
+
693
+ <h3 class="section-title" style="margin:0 0 0.4rem">Portals</h3>
694
+ <div id="portal-list" style="font-size:0.65rem;color:var(--text-secondary);margin-bottom:0.3rem">No portals in this scene</div>
695
+ <div class="input-group" style="margin-bottom:0.3rem">
696
+ <label>Navigate to Scene ID</label>
697
+ <div style="display:flex;gap:0.25rem;">
698
+ <input type="text" id="portal-scene-id" placeholder="Enter scene ID..." style="flex:1">
699
+ <button class="btn btn-primary" onclick="navigateToScene()" id="btn-navigate" disabled style="white-space:nowrap">Go</button>
700
+ </div>
701
+ </div>
702
+
703
+ </div>
704
+ </div>
705
+ </div>
706
+ </main>
707
+
708
+ <!-- Load StorySplat Viewer from CDN -->
709
+ <script src="https://cdn.jsdelivr.net/npm/playcanvas@2.14.3/build/playcanvas.min.js"></script>
710
+ <script src="https://unpkg.com/storysplat-viewer@2/dist/storysplat-viewer.umd.js"></script>
711
+
712
+ <script>
713
+ // ============================================
714
+ // StorySplat Viewer API Demo
715
+ // ============================================
716
+ // This demo showcases all the ways the StorySplat Viewer
717
+ // can be controlled programmatically via external buttons.
718
+ //
719
+ // Documentation: https://docs.storysplat.com
720
+ // NPM Package: https://www.npmjs.com/package/storysplat-viewer
721
+ // ============================================
722
+
723
+ // Global viewer instance
724
+ let viewer = null;
725
+ let sceneData = null;
726
+
727
+ // DOM references
728
+ const elements = {
729
+ viewerContainer: document.getElementById('viewer-container'),
730
+ viewerLoading: document.getElementById('viewer-loading'),
731
+ statusDot: document.getElementById('status-dot'),
732
+ statusText: document.getElementById('status-text'),
733
+ sceneInfo: document.getElementById('scene-info'),
734
+ waypointSelect: document.getElementById('waypoint-select'),
735
+ currentWaypoint: document.getElementById('current-waypoint'),
736
+ totalWaypoints: document.getElementById('total-waypoints'),
737
+ cameraPosition: document.getElementById('camera-position'),
738
+ cameraRotation: document.getElementById('camera-rotation'),
739
+ playbackIndicator: document.getElementById('playback-indicator'),
740
+ playbackStatusText: document.getElementById('playback-status-text'),
741
+ eventLog: document.getElementById('event-log'),
742
+ // Buttons
743
+ btnPrev: document.getElementById('btn-prev'),
744
+ btnNext: document.getElementById('btn-next'),
745
+ btnPlay: document.getElementById('btn-play'),
746
+ btnPause: document.getElementById('btn-pause'),
747
+ btnStop: document.getElementById('btn-stop'),
748
+ btnApplyPos: document.getElementById('btn-apply-pos'),
749
+ btnApplyRot: document.getElementById('btn-apply-rot'),
750
+ // Mode buttons
751
+ btnModeTour: document.getElementById('btn-mode-tour'),
752
+ btnModeExplore: document.getElementById('btn-mode-explore'),
753
+ btnExploreOrbit: document.getElementById('btn-explore-orbit'),
754
+ btnExploreFly: document.getElementById('btn-explore-fly'),
755
+ exploreSubSeparator: document.getElementById('explore-sub-separator'),
756
+ modeDisplay: document.getElementById('mode-display'),
757
+ // Progress
758
+ progressSlider: document.getElementById('progress-slider'),
759
+ progressDisplay: document.getElementById('progress-display'),
760
+ btnApplyProgress: document.getElementById('btn-apply-progress'),
761
+ // Audio
762
+ btnMute: document.getElementById('btn-mute'),
763
+ btnUnmute: document.getElementById('btn-unmute'),
764
+ muteStatus: document.getElementById('mute-status'),
765
+ // Splat swap
766
+ splatSelect: document.getElementById('splat-select'),
767
+ btnGoSplat: document.getElementById('btn-go-splat'),
768
+ btnOriginalSplat: document.getElementById('btn-original-splat'),
769
+ currentSplatDisplay: document.getElementById('current-splat'),
770
+ // Hotspots
771
+ hotspotSelect: document.getElementById('hotspot-select'),
772
+ btnTriggerHotspot: document.getElementById('btn-trigger-hotspot'),
773
+ btnCloseHotspot: document.getElementById('btn-close-hotspot'),
774
+ };
775
+
776
+ // ============================================
777
+ // Event Logging
778
+ // ============================================
779
+
780
+ /**
781
+ * Add an entry to the event log
782
+ * @param {string} eventName - Name of the event
783
+ * @param {any} data - Event data (optional)
784
+ * @param {string} type - Log type: 'info', 'success', 'warning', 'error'
785
+ */
786
+ function logEvent(eventName, data = null, type = 'info') {
787
+ const log = elements.eventLog;
788
+ const entry = document.createElement('div');
789
+ entry.className = `log-entry ${type}`;
790
+
791
+ const time = new Date().toLocaleTimeString('en-US', {
792
+ hour12: false,
793
+ hour: '2-digit',
794
+ minute: '2-digit',
795
+ second: '2-digit'
796
+ });
797
+
798
+ const dataStr = data ? JSON.stringify(data) : '';
799
+
800
+ entry.innerHTML = `
801
+ <span class="log-time">${time}</span>
802
+ <span class="log-event">${eventName}</span>
803
+ <span class="log-data">${dataStr}</span>
804
+ `;
805
+
806
+ log.appendChild(entry);
807
+ log.scrollTop = log.scrollHeight;
808
+ }
809
+
810
+ /**
811
+ * Clear the event log
812
+ */
813
+ function clearLog() {
814
+ elements.eventLog.innerHTML = '';
815
+ logEvent('log_cleared', null, 'info');
816
+ }
817
+
818
+ // ============================================
819
+ // Status Updates
820
+ // ============================================
821
+
822
+ /**
823
+ * Update the viewer status indicator
824
+ * @param {string} status - 'loading', 'ready', 'error'
825
+ * @param {string} text - Status message
826
+ */
827
+ function updateStatus(status, text) {
828
+ elements.statusDot.className = 'status-dot';
829
+ if (status === 'ready') elements.statusDot.classList.add('ready');
830
+ if (status === 'error') elements.statusDot.classList.add('error');
831
+ elements.statusText.textContent = text;
832
+ }
833
+
834
+ /**
835
+ * Enable/disable control buttons based on viewer state
836
+ * @param {boolean} enabled - Whether controls should be enabled
837
+ */
838
+ function setControlsEnabled(enabled) {
839
+ if (!enabled) {
840
+ elements.btnPrev.disabled = true;
841
+ elements.btnNext.disabled = true;
842
+ elements.btnPlay.disabled = true;
843
+ elements.btnPause.disabled = true;
844
+ elements.btnStop.disabled = true;
845
+ elements.btnApplyPos.disabled = true;
846
+ elements.btnApplyRot.disabled = true;
847
+ elements.btnApplyProgress.disabled = true;
848
+ elements.btnMute.disabled = true;
849
+ elements.btnUnmute.disabled = true;
850
+ elements.splatSelect.disabled = true;
851
+ elements.btnGoSplat.disabled = true;
852
+ elements.btnOriginalSplat.disabled = true;
853
+ elements.hotspotSelect.disabled = true;
854
+ elements.btnTriggerHotspot.disabled = true;
855
+ elements.btnCloseHotspot.disabled = true;
856
+ } else {
857
+ // Apply mode-aware enable/disable
858
+ updateModeUI(currentMode);
859
+ // Enable audio, splat, hotspot controls (mode-independent)
860
+ elements.btnMute.disabled = false;
861
+ elements.btnUnmute.disabled = false;
862
+ elements.btnCloseHotspot.disabled = false;
863
+ elements.btnOriginalSplat.disabled = false;
864
+ // Populate dropdowns
865
+ populateSplatSelect();
866
+ populateHotspotSelect();
867
+ }
868
+ document.getElementById('btn-navigate').disabled = !enabled;
869
+ }
870
+
871
+ // ============================================
872
+ // Scene Metadata (fetchSceneMeta API)
873
+ // ============================================
874
+
875
+ /**
876
+ * Fetch and display scene metadata
877
+ * Uses the fetchSceneMeta() function to get scene info without loading the viewer
878
+ *
879
+ * API: StorySplatViewer.fetchSceneMeta(sceneId, options?)
880
+ * Returns: { name, description, thumbnailUrl, userName, views, tags, createdAt }
881
+ */
882
+ async function loadSceneInfo(sceneId) {
883
+ elements.sceneInfo.innerHTML = '<div class="scene-info-item"><span class="scene-info-label">Loading...</span></div>';
884
+
885
+ try {
886
+ // fetchSceneMeta fetches metadata without creating a viewer
887
+ const meta = await StorySplatViewer.fetchSceneMeta(sceneId);
888
+
889
+ logEvent('fetchSceneMeta', { name: meta.name, views: meta.views }, 'success');
890
+
891
+ elements.sceneInfo.innerHTML = `
892
+ <div class="scene-info-item">
893
+ <span class="scene-info-label">Name</span>
894
+ <span class="scene-info-value">${meta.name || 'Untitled'}</span>
895
+ </div>
896
+ <div class="scene-info-item">
897
+ <span class="scene-info-label">Description</span>
898
+ <span class="scene-info-value">${meta.description || 'No description'}</span>
899
+ </div>
900
+ <div class="scene-info-item">
901
+ <span class="scene-info-label">Author</span>
902
+ <span class="scene-info-value">${meta.userName || 'Unknown'}</span>
903
+ </div>
904
+ <div class="scene-info-item">
905
+ <span class="scene-info-label">Views</span>
906
+ <span class="scene-info-value">${meta.views?.toLocaleString() || 0}</span>
907
+ </div>
908
+ <div class="scene-info-item">
909
+ <span class="scene-info-label">Tags</span>
910
+ <span class="scene-info-value">${meta.tags?.join(', ') || 'None'}</span>
911
+ </div>
912
+ <div class="scene-info-item">
913
+ <span class="scene-info-label">Created</span>
914
+ <span class="scene-info-value">${meta.createdAt ? new Date(meta.createdAt).toLocaleDateString() : 'Unknown'}</span>
915
+ </div>
916
+ `;
917
+ } catch (error) {
918
+ logEvent('fetchSceneMeta_error', { error: error.message }, 'error');
919
+ elements.sceneInfo.innerHTML = `
920
+ <div class="scene-info-item">
921
+ <span class="scene-info-label" style="color: var(--accent-error)">Error: ${error.message}</span>
922
+ </div>
923
+ `;
924
+ }
925
+ }
926
+
927
+ // ============================================
928
+ // Scene Loading (createViewerFromSceneId API)
929
+ // ============================================
930
+
931
+ /**
932
+ * Load a scene from the StorySplat API
933
+ *
934
+ * API: StorySplatViewer.createViewerFromSceneId(container, sceneId, options?)
935
+ * Returns: ViewerInstance with all control methods
936
+ */
937
+ async function loadScene() {
938
+ const sceneId = document.getElementById('scene-id').value.trim();
939
+ if (!sceneId) {
940
+ alert('Please enter a scene ID');
941
+ return;
942
+ }
943
+
944
+ // Destroy existing viewer if present
945
+ if (viewer) {
946
+ viewer.destroy();
947
+ viewer = null;
948
+ logEvent('viewer_destroyed', null, 'info');
949
+ }
950
+
951
+ // Reset UI
952
+ setControlsEnabled(false);
953
+ updateStatus('loading', 'Loading scene...');
954
+ elements.viewerLoading.style.display = 'flex';
955
+ elements.waypointSelect.innerHTML = '<option value="">Loading...</option>';
956
+
957
+ // Load scene info and portal list in parallel
958
+ loadSceneInfo(sceneId);
959
+ loadPortalList(sceneId);
960
+
961
+ try {
962
+ logEvent('createViewerFromSceneId', { sceneId }, 'info');
963
+
964
+ // Create viewer from scene ID
965
+ // This fetches scene data from StorySplat API and creates the viewer
966
+ viewer = await StorySplatViewer.createViewerFromSceneId(
967
+ elements.viewerContainer,
968
+ sceneId,
969
+ {
970
+ // Viewer options
971
+ showUI: true, // Show built-in navigation UI
972
+ autoPlay: false, // Don't auto-play waypoints
973
+ revealEffect: 'medium', // Reveal animation speed
974
+ // lazyLoad: false, // Don't use lazy loading (show viewer immediately)
975
+ }
976
+ );
977
+
978
+ // Hide loading indicator
979
+ elements.viewerLoading.style.display = 'none';
980
+
981
+ // Setup event listeners
982
+ setupEventListeners();
983
+
984
+ // Enable controls
985
+ setControlsEnabled(true);
986
+ updateStatus('ready', 'Scene loaded');
987
+
988
+ // Initialize waypoint UI
989
+ updateWaypointUI();
990
+
991
+ // Start camera position polling
992
+ startCameraPolling();
993
+
994
+ logEvent('viewer_created', null, 'success');
995
+
996
+ } catch (error) {
997
+ logEvent('load_error', { error: error.message }, 'error');
998
+ updateStatus('error', `Error: ${error.message}`);
999
+ elements.viewerLoading.innerHTML = `
1000
+ <div style="color: var(--accent-error);">
1001
+ Failed to load scene: ${error.message}
1002
+ </div>
1003
+ `;
1004
+ }
1005
+ }
1006
+
1007
+ // ============================================
1008
+ // Event Listeners (viewer.on API)
1009
+ // ============================================
1010
+
1011
+ /**
1012
+ * Setup all viewer event listeners
1013
+ *
1014
+ * API: viewer.on(eventName, callback)
1015
+ * Events: 'ready', 'loaded', 'progress', 'waypointChange',
1016
+ * 'playbackStart', 'playbackStop', 'error', 'warning'
1017
+ */
1018
+ function setupEventListeners() {
1019
+ // 'ready' - Viewer has initialized and is ready
1020
+ viewer.on('ready', () => {
1021
+ logEvent('ready', null, 'success');
1022
+ updateStatus('ready', 'Viewer ready');
1023
+ });
1024
+
1025
+ // 'loaded' - Scene has fully loaded (splat data loaded)
1026
+ viewer.on('loaded', (data) => {
1027
+ logEvent('loaded', {
1028
+ bandwidthUsed: data?.bandwidthUsed ? `${(data.bandwidthUsed / 1024 / 1024).toFixed(2)}MB` : 'unknown',
1029
+ isStorySplatHosted: data?.isStorySplatHosted
1030
+ }, 'success');
1031
+ });
1032
+
1033
+ // 'progress' - Loading progress updates
1034
+ viewer.on('progress', (data) => {
1035
+ logEvent('progress', { percent: data?.percent || 0 }, 'info');
1036
+ });
1037
+
1038
+ // 'waypointChange' - Current waypoint changed
1039
+ viewer.on('waypointChange', ({ index, waypoint }) => {
1040
+ logEvent('waypointChange', {
1041
+ index,
1042
+ name: waypoint?.name || `Waypoint ${index + 1}`
1043
+ }, 'info');
1044
+ updateWaypointUI();
1045
+ });
1046
+
1047
+ // 'playbackStart' - Auto-play started
1048
+ viewer.on('playbackStart', () => {
1049
+ logEvent('playbackStart', null, 'success');
1050
+ updatePlaybackUI(true);
1051
+ });
1052
+
1053
+ // 'playbackStop' - Auto-play stopped
1054
+ viewer.on('playbackStop', () => {
1055
+ logEvent('playbackStop', null, 'info');
1056
+ updatePlaybackUI(false);
1057
+ });
1058
+
1059
+ // 'error' - An error occurred
1060
+ viewer.on('error', (error) => {
1061
+ logEvent('error', { message: error?.message || error }, 'error');
1062
+ });
1063
+
1064
+ // 'warning' - A warning occurred (e.g., deprecated format)
1065
+ viewer.on('warning', (warning) => {
1066
+ logEvent('warning', {
1067
+ type: warning?.type,
1068
+ message: warning?.message
1069
+ }, 'warning');
1070
+ });
1071
+
1072
+ // 'portalActivated' - A portal was triggered
1073
+ viewer.on('portalActivated', (data) => {
1074
+ logEvent('portalActivated', {
1075
+ portalId: data?.portalId,
1076
+ targetSceneId: data?.targetSceneId,
1077
+ targetSceneName: data?.targetSceneName
1078
+ }, 'success');
1079
+ });
1080
+ }
1081
+
1082
+ // ============================================
1083
+ // Navigation Controls (Waypoint API)
1084
+ // ============================================
1085
+
1086
+ /**
1087
+ * Go to the previous waypoint
1088
+ * API: viewer.prevWaypoint()
1089
+ */
1090
+ function prevWaypoint() {
1091
+ if (!viewer) return;
1092
+ try {
1093
+ viewer.prevWaypoint();
1094
+ logEvent('API_call', { method: 'prevWaypoint()' }, 'info');
1095
+ } catch (e) {
1096
+ logEvent('error', { method: 'prevWaypoint', message: e.message }, 'error');
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * Go to the next waypoint
1102
+ * API: viewer.nextWaypoint()
1103
+ */
1104
+ function nextWaypoint() {
1105
+ if (!viewer) return;
1106
+ try {
1107
+ viewer.nextWaypoint();
1108
+ logEvent('API_call', { method: 'nextWaypoint()' }, 'info');
1109
+ } catch (e) {
1110
+ logEvent('error', { method: 'nextWaypoint', message: e.message }, 'error');
1111
+ }
1112
+ }
1113
+
1114
+ /**
1115
+ * Go to a specific waypoint by index
1116
+ * API: viewer.goToWaypoint(index)
1117
+ */
1118
+ function goToSelectedWaypoint() {
1119
+ if (!viewer) return;
1120
+ const select = elements.waypointSelect;
1121
+ const index = parseInt(select.value, 10);
1122
+ if (!isNaN(index)) {
1123
+ try {
1124
+ viewer.goToWaypoint(index);
1125
+ logEvent('API_call', { method: `goToWaypoint(${index})` }, 'info');
1126
+ } catch (e) {
1127
+ logEvent('error', { method: 'goToWaypoint', message: e.message }, 'error');
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Update the waypoint UI (current index, total, select dropdown)
1134
+ * Uses: viewer.getCurrentWaypointIndex(), viewer.getWaypointCount()
1135
+ */
1136
+ function updateWaypointUI() {
1137
+ if (!viewer) return;
1138
+
1139
+ const currentIndex = viewer.getCurrentWaypointIndex();
1140
+ const totalWaypoints = viewer.getWaypointCount();
1141
+
1142
+ elements.currentWaypoint.textContent = currentIndex + 1;
1143
+ elements.totalWaypoints.textContent = totalWaypoints;
1144
+
1145
+ // Update prev/next button states
1146
+ elements.btnPrev.disabled = currentIndex <= 0;
1147
+ elements.btnNext.disabled = currentIndex >= totalWaypoints - 1;
1148
+
1149
+ // Populate waypoint dropdown
1150
+ elements.waypointSelect.innerHTML = '<option value="">Select waypoint...</option>';
1151
+ for (let i = 0; i < totalWaypoints; i++) {
1152
+ const option = document.createElement('option');
1153
+ option.value = i;
1154
+ option.textContent = `Waypoint ${i + 1}`;
1155
+ if (i === currentIndex) {
1156
+ option.selected = true;
1157
+ }
1158
+ elements.waypointSelect.appendChild(option);
1159
+ }
1160
+ }
1161
+
1162
+ // ============================================
1163
+ // Playback Controls (Play/Pause/Stop API)
1164
+ // ============================================
1165
+
1166
+ /**
1167
+ * Start auto-playing through waypoints
1168
+ * API: viewer.play()
1169
+ */
1170
+ function play() {
1171
+ if (!viewer) return;
1172
+ try {
1173
+ viewer.play();
1174
+ logEvent('API_call', { method: 'play()' }, 'info');
1175
+ } catch (e) {
1176
+ logEvent('error', { method: 'play', message: e.message }, 'error');
1177
+ }
1178
+ }
1179
+
1180
+ /**
1181
+ * Pause auto-play
1182
+ * API: viewer.pause()
1183
+ */
1184
+ function pause() {
1185
+ if (!viewer) return;
1186
+ try {
1187
+ viewer.pause();
1188
+ logEvent('API_call', { method: 'pause()' }, 'info');
1189
+ } catch (e) {
1190
+ logEvent('error', { method: 'pause', message: e.message }, 'error');
1191
+ }
1192
+ }
1193
+
1194
+ /**
1195
+ * Stop auto-play and reset to first waypoint
1196
+ * API: viewer.stop()
1197
+ */
1198
+ function stop() {
1199
+ if (!viewer) return;
1200
+ try {
1201
+ viewer.stop();
1202
+ logEvent('API_call', { method: 'stop()' }, 'info');
1203
+ } catch (e) {
1204
+ logEvent('error', { method: 'stop', message: e.message }, 'error');
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Update playback UI based on playing state
1210
+ * @param {boolean} isPlaying - Whether playback is active
1211
+ */
1212
+ function updatePlaybackUI(isPlaying) {
1213
+ elements.playbackIndicator.className = 'playback-indicator' + (isPlaying ? ' playing' : '');
1214
+ elements.playbackStatusText.textContent = isPlaying ? 'Playing...' : 'Stopped';
1215
+ }
1216
+
1217
+ // ============================================
1218
+ // Camera Controls (Position/Rotation API)
1219
+ // ============================================
1220
+
1221
+ /**
1222
+ * Apply a new camera position
1223
+ * API: viewer.setPosition(x, y, z)
1224
+ */
1225
+ function applyPosition() {
1226
+ if (!viewer) return;
1227
+ const x = parseFloat(document.getElementById('pos-x').value) || 0;
1228
+ const y = parseFloat(document.getElementById('pos-y').value) || 0;
1229
+ const z = parseFloat(document.getElementById('pos-z').value) || 0;
1230
+ try {
1231
+ viewer.setPosition(x, y, z);
1232
+ logEvent('API_call', { method: `setPosition(${x}, ${y}, ${z})` }, 'info');
1233
+ } catch (e) {
1234
+ logEvent('error', { method: 'setPosition', message: e.message }, 'error');
1235
+ }
1236
+ }
1237
+
1238
+ /**
1239
+ * Apply a new camera rotation (Euler angles in degrees)
1240
+ * API: viewer.setRotation(x, y, z)
1241
+ */
1242
+ function applyRotation() {
1243
+ if (!viewer) return;
1244
+ const x = parseFloat(document.getElementById('rot-x').value) || 0;
1245
+ const y = parseFloat(document.getElementById('rot-y').value) || 0;
1246
+ const z = parseFloat(document.getElementById('rot-z').value) || 0;
1247
+ try {
1248
+ viewer.setRotation(x, y, z);
1249
+ logEvent('API_call', { method: `setRotation(${x}, ${y}, ${z})` }, 'info');
1250
+ } catch (e) {
1251
+ logEvent('error', { method: 'setRotation', message: e.message }, 'error');
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * Poll camera position/rotation and update display
1257
+ * Uses: viewer.getPosition(), viewer.getRotation()
1258
+ */
1259
+ let cameraPollingInterval = null;
1260
+
1261
+ function startCameraPolling() {
1262
+ // Clear existing interval
1263
+ if (cameraPollingInterval) {
1264
+ clearInterval(cameraPollingInterval);
1265
+ }
1266
+
1267
+ // Poll every 100ms
1268
+ cameraPollingInterval = setInterval(() => {
1269
+ if (!viewer) {
1270
+ clearInterval(cameraPollingInterval);
1271
+ return;
1272
+ }
1273
+
1274
+ try {
1275
+ const pos = viewer.getPosition();
1276
+ const rot = viewer.getRotation();
1277
+
1278
+ if (pos) {
1279
+ elements.cameraPosition.textContent =
1280
+ `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}`;
1281
+ }
1282
+
1283
+ if (rot) {
1284
+ elements.cameraRotation.textContent =
1285
+ `x: ${rot.x.toFixed(1)}, y: ${rot.y.toFixed(1)}, z: ${rot.z.toFixed(1)}`;
1286
+ }
1287
+
1288
+ // Also update playback status
1289
+ if (viewer.isPlaying && !viewer.isPlaying()) {
1290
+ updatePlaybackUI(false);
1291
+ }
1292
+
1293
+ // Sync progress slider (only if user isn't dragging)
1294
+ if (viewer.getProgress && document.activeElement !== elements.progressSlider) {
1295
+ const progress = viewer.getProgress();
1296
+ elements.progressSlider.value = Math.round(progress * 100);
1297
+ elements.progressDisplay.textContent = Math.round(progress * 100) + '%';
1298
+ }
1299
+
1300
+ // Update mute status
1301
+ if (viewer.isMuted) {
1302
+ elements.muteStatus.textContent = viewer.isMuted() ? 'muted' : 'unmuted';
1303
+ }
1304
+ } catch (e) {
1305
+ // Viewer might not be ready yet
1306
+ }
1307
+ }, 100);
1308
+ }
1309
+
1310
+ // ============================================
1311
+ // Portal Controls (navigateToScene API)
1312
+ // ============================================
1313
+
1314
+ /**
1315
+ * Navigate to a different scene via portal API
1316
+ * API: viewer.navigateToScene(sceneId)
1317
+ */
1318
+ async function navigateToScene() {
1319
+ if (!viewer) return;
1320
+ const targetId = document.getElementById('portal-scene-id').value.trim();
1321
+ if (!targetId) {
1322
+ alert('Please enter a scene ID');
1323
+ return;
1324
+ }
1325
+ try {
1326
+ logEvent('API_call', { method: `navigateToScene("${targetId}")` }, 'info');
1327
+ await viewer.navigateToScene(targetId);
1328
+ logEvent('navigateToScene', { targetSceneId: targetId }, 'success');
1329
+ } catch (error) {
1330
+ logEvent('navigateToScene_error', { error: error.message }, 'error');
1331
+ }
1332
+ }
1333
+
1334
+ /**
1335
+ * Fetch full scene data to get portal list
1336
+ */
1337
+ async function loadPortalList(sceneId) {
1338
+ const portalListEl = document.getElementById('portal-list');
1339
+ try {
1340
+ const resp = await fetch(`https://discover.storysplat.com/api/scene/${encodeURIComponent(sceneId)}`);
1341
+ if (!resp.ok) throw new Error(`${resp.status}`);
1342
+ const data = await resp.json();
1343
+ const portals = data?.portals || [];
1344
+ if (portals.length === 0) {
1345
+ portalListEl.textContent = 'No portals in this scene';
1346
+ return;
1347
+ }
1348
+ logEvent('portals_found', { count: portals.length }, 'info');
1349
+ portalListEl.innerHTML = portals.map((p, i) => {
1350
+ const name = p.targetSceneName || p.title || `Portal ${i + 1}`;
1351
+ const target = p.targetSceneId || 'unknown';
1352
+ return `<div style="display:flex;align-items:center;gap:0.3rem;margin-bottom:0.2rem;">
1353
+ <span style="color:var(--text-primary)">${name}</span>
1354
+ <span style="color:var(--text-muted);font-size:0.6rem">(${target})</span>
1355
+ ${p.targetSceneId ? `<button class="btn" style="padding:0.15rem 0.4rem;font-size:0.6rem;" onclick="document.getElementById('portal-scene-id').value='${p.targetSceneId}';navigateToScene()">Go</button>` : ''}
1356
+ </div>`;
1357
+ }).join('');
1358
+ } catch (e) {
1359
+ portalListEl.textContent = 'Could not load portals';
1360
+ }
1361
+ }
1362
+
1363
+ // ============================================
1364
+ // Progress Controls (setProgress / getProgress API)
1365
+ // ============================================
1366
+
1367
+ function updateProgressDisplay() {
1368
+ const val = elements.progressSlider.value;
1369
+ elements.progressDisplay.textContent = val + '%';
1370
+ }
1371
+
1372
+ /**
1373
+ * Apply progress value to the tour
1374
+ * API: viewer.setProgress(0-1)
1375
+ */
1376
+ function applyProgress() {
1377
+ if (!viewer) return;
1378
+ const progress = parseFloat(elements.progressSlider.value) / 100;
1379
+ try {
1380
+ viewer.setProgress(progress);
1381
+ logEvent('API_call', { method: `setProgress(${progress.toFixed(2)})` }, 'info');
1382
+ } catch (e) {
1383
+ logEvent('error', { method: 'setProgress', message: e.message }, 'error');
1384
+ }
1385
+ }
1386
+
1387
+ // ============================================
1388
+ // Audio Controls (muteAll / unmuteAll API)
1389
+ // ============================================
1390
+
1391
+ function muteAll() {
1392
+ if (!viewer) return;
1393
+ try {
1394
+ viewer.muteAll();
1395
+ logEvent('API_call', { method: 'muteAll()' }, 'info');
1396
+ elements.muteStatus.textContent = 'muted';
1397
+ } catch (e) {
1398
+ logEvent('error', { method: 'muteAll', message: e.message }, 'error');
1399
+ }
1400
+ }
1401
+
1402
+ function unmuteAll() {
1403
+ if (!viewer) return;
1404
+ try {
1405
+ viewer.unmuteAll();
1406
+ logEvent('API_call', { method: 'unmuteAll()' }, 'info');
1407
+ elements.muteStatus.textContent = 'unmuted';
1408
+ } catch (e) {
1409
+ logEvent('error', { method: 'unmuteAll', message: e.message }, 'error');
1410
+ }
1411
+ }
1412
+
1413
+ // ============================================
1414
+ // Splat Swap Controls (goToSplat / goToOriginalSplat API)
1415
+ // ============================================
1416
+
1417
+ function populateSplatSelect() {
1418
+ if (!viewer) return;
1419
+ try {
1420
+ const splats = viewer.getAdditionalSplats();
1421
+ elements.splatSelect.innerHTML = '';
1422
+ if (splats.length === 0) {
1423
+ elements.splatSelect.innerHTML = '<option value="">No additional splats</option>';
1424
+ elements.splatSelect.disabled = true;
1425
+ elements.btnGoSplat.disabled = true;
1426
+ } else {
1427
+ elements.splatSelect.innerHTML = '<option value="">Select splat...</option>';
1428
+ splats.forEach((s, i) => {
1429
+ const opt = document.createElement('option');
1430
+ opt.value = s.url;
1431
+ opt.textContent = s.name || `Splat ${i + 1}`;
1432
+ elements.splatSelect.appendChild(opt);
1433
+ });
1434
+ elements.splatSelect.disabled = false;
1435
+ elements.btnGoSplat.disabled = false;
1436
+ }
1437
+ elements.btnOriginalSplat.disabled = false;
1438
+ updateCurrentSplatDisplay();
1439
+ } catch (e) {
1440
+ logEvent('error', { method: 'getAdditionalSplats', message: e.message }, 'error');
1441
+ }
1442
+ }
1443
+
1444
+ function updateCurrentSplatDisplay() {
1445
+ if (!viewer) return;
1446
+ try {
1447
+ const url = viewer.getCurrentSplatUrl();
1448
+ const isOriginal = viewer.isShowingOriginalSplat();
1449
+ const shortUrl = url.length > 40 ? '...' + url.slice(-37) : url;
1450
+ elements.currentSplatDisplay.textContent = `Current: ${isOriginal ? '(original) ' : ''}${shortUrl}`;
1451
+ } catch (e) {
1452
+ elements.currentSplatDisplay.textContent = 'Current: unknown';
1453
+ }
1454
+ }
1455
+
1456
+ async function goToSelectedSplat() {
1457
+ if (!viewer) return;
1458
+ const url = elements.splatSelect.value;
1459
+ if (!url) return;
1460
+ try {
1461
+ logEvent('API_call', { method: `goToSplat("${url.slice(-30)}...")` }, 'info');
1462
+ await viewer.goToSplat(url);
1463
+ logEvent('goToSplat', { success: true }, 'success');
1464
+ updateCurrentSplatDisplay();
1465
+ } catch (e) {
1466
+ logEvent('error', { method: 'goToSplat', message: e.message }, 'error');
1467
+ }
1468
+ }
1469
+
1470
+ function goToOriginalSplat() {
1471
+ if (!viewer) return;
1472
+ try {
1473
+ viewer.goToOriginalSplat();
1474
+ logEvent('API_call', { method: 'goToOriginalSplat()' }, 'info');
1475
+ updateCurrentSplatDisplay();
1476
+ } catch (e) {
1477
+ logEvent('error', { method: 'goToOriginalSplat', message: e.message }, 'error');
1478
+ }
1479
+ }
1480
+
1481
+ // ============================================
1482
+ // Hotspot Controls (getHotspots / triggerHotspot / closeHotspot API)
1483
+ // ============================================
1484
+
1485
+ function populateHotspotSelect() {
1486
+ if (!viewer) return;
1487
+ try {
1488
+ const hotspots = viewer.getHotspots();
1489
+ elements.hotspotSelect.innerHTML = '';
1490
+ if (hotspots.length === 0) {
1491
+ elements.hotspotSelect.innerHTML = '<option value="">No hotspots</option>';
1492
+ elements.hotspotSelect.disabled = true;
1493
+ elements.btnTriggerHotspot.disabled = true;
1494
+ } else {
1495
+ elements.hotspotSelect.innerHTML = '<option value="">Select hotspot...</option>';
1496
+ hotspots.forEach((h, i) => {
1497
+ const opt = document.createElement('option');
1498
+ opt.value = h.id;
1499
+ opt.textContent = h.title || `Hotspot ${i + 1} (${h.type})`;
1500
+ elements.hotspotSelect.appendChild(opt);
1501
+ });
1502
+ elements.hotspotSelect.disabled = false;
1503
+ elements.btnTriggerHotspot.disabled = false;
1504
+ }
1505
+ elements.btnCloseHotspot.disabled = false;
1506
+ logEvent('getHotspots', { count: hotspots.length }, 'info');
1507
+ } catch (e) {
1508
+ logEvent('error', { method: 'getHotspots', message: e.message }, 'error');
1509
+ }
1510
+ }
1511
+
1512
+ function triggerSelectedHotspot() {
1513
+ if (!viewer) return;
1514
+ const id = elements.hotspotSelect.value;
1515
+ if (!id) return;
1516
+ try {
1517
+ viewer.triggerHotspot(id);
1518
+ logEvent('API_call', { method: `triggerHotspot("${id}")` }, 'info');
1519
+ } catch (e) {
1520
+ logEvent('error', { method: 'triggerHotspot', message: e.message }, 'error');
1521
+ }
1522
+ }
1523
+
1524
+ function closeHotspot() {
1525
+ if (!viewer) return;
1526
+ try {
1527
+ viewer.closeHotspot();
1528
+ logEvent('API_call', { method: 'closeHotspot()' }, 'info');
1529
+ } catch (e) {
1530
+ logEvent('error', { method: 'closeHotspot', message: e.message }, 'error');
1531
+ }
1532
+ }
1533
+
1534
+ // ============================================
1535
+ // Mode Controls (setCameraMode / setExploreMode API)
1536
+ // ============================================
1537
+
1538
+ let currentMode = 'tour';
1539
+
1540
+ /**
1541
+ * Switch between tour and explore mode
1542
+ * API: viewer.setCameraMode('tour' | 'explore')
1543
+ */
1544
+ function setMode(mode) {
1545
+ if (!viewer) return;
1546
+ try {
1547
+ viewer.setCameraMode(mode);
1548
+ currentMode = mode;
1549
+ logEvent('API_call', { method: `setCameraMode("${mode}")` }, 'info');
1550
+ updateModeUI(mode);
1551
+ } catch (e) {
1552
+ logEvent('error', { method: 'setCameraMode', message: e.message }, 'error');
1553
+ }
1554
+ }
1555
+
1556
+ /**
1557
+ * Switch explore sub-mode between orbit and fly
1558
+ * API: viewer.setExploreMode('orbit' | 'fly')
1559
+ */
1560
+ function setExploreMode(mode) {
1561
+ if (!viewer) return;
1562
+ try {
1563
+ viewer.setExploreMode(mode);
1564
+ logEvent('API_call', { method: `setExploreMode("${mode}")` }, 'info');
1565
+ updateExploreModeUI(mode);
1566
+ } catch (e) {
1567
+ logEvent('error', { method: 'setExploreMode', message: e.message }, 'error');
1568
+ }
1569
+ }
1570
+
1571
+ /**
1572
+ * Update the mode UI: toggle button styles, show/hide explore sub-buttons,
1573
+ * enable/disable controls based on mode
1574
+ */
1575
+ function updateModeUI(mode) {
1576
+ // Toggle button active styles
1577
+ elements.btnModeTour.className = mode === 'tour' ? 'btn btn-primary' : 'btn';
1578
+ elements.btnModeExplore.className = mode === 'explore' ? 'btn btn-primary' : 'btn';
1579
+
1580
+ // Show/hide explore sub-buttons
1581
+ const showSub = mode === 'explore';
1582
+ elements.exploreSubSeparator.className = showSub ? '' : 'hidden-mode';
1583
+ elements.btnExploreOrbit.className = showSub ? 'btn mode-active' : 'btn hidden-mode';
1584
+ elements.btnExploreFly.className = showSub ? 'btn' : 'btn hidden-mode';
1585
+
1586
+ // Update display
1587
+ elements.modeDisplay.textContent = mode;
1588
+
1589
+ // Tour mode: enable nav/playback/progress, disable set pos/rot
1590
+ // Explore mode: disable nav/playback/progress, enable set pos/rot
1591
+ const isTour = mode === 'tour';
1592
+ elements.btnPrev.disabled = !isTour;
1593
+ elements.btnNext.disabled = !isTour;
1594
+ elements.btnPlay.disabled = !isTour;
1595
+ elements.btnPause.disabled = !isTour;
1596
+ elements.btnStop.disabled = !isTour;
1597
+ elements.waypointSelect.disabled = !isTour;
1598
+ elements.btnApplyProgress.disabled = !isTour;
1599
+ elements.progressSlider.disabled = !isTour;
1600
+ elements.btnApplyPos.disabled = isTour;
1601
+ elements.btnApplyRot.disabled = isTour;
1602
+
1603
+ // Update tooltips
1604
+ elements.btnApplyPos.title = isTour ? 'Switch to Explore mode first' : '';
1605
+ elements.btnApplyRot.title = isTour ? 'Switch to Explore mode first' : '';
1606
+ elements.btnApplyProgress.title = !isTour ? 'Switch to Tour mode first' : '';
1607
+ elements.btnPrev.title = !isTour ? 'Switch to Tour mode first' : '';
1608
+ elements.btnNext.title = !isTour ? 'Switch to Tour mode first' : '';
1609
+ elements.btnPlay.title = !isTour ? 'Switch to Tour mode first' : '';
1610
+ }
1611
+
1612
+ function updateExploreModeUI(mode) {
1613
+ elements.btnExploreOrbit.className = mode === 'orbit' ? 'btn mode-active' : 'btn';
1614
+ elements.btnExploreFly.className = mode === 'fly' ? 'btn mode-active' : 'btn';
1615
+ elements.modeDisplay.textContent = `explore (${mode})`;
1616
+ }
1617
+
1618
+ // ============================================
1619
+ // Initialize
1620
+ // ============================================
1621
+
1622
+ // Auto-load the default scene on page load
1623
+ document.addEventListener('DOMContentLoaded', () => {
1624
+ logEvent('page_loaded', {
1625
+ cdnVersion: 'storysplat-viewer@2'
1626
+ }, 'info');
1627
+
1628
+ // Load the scene automatically
1629
+ loadScene();
1630
+ });
1631
+
1632
+ // Cleanup on page unload
1633
+ window.addEventListener('beforeunload', () => {
1634
+ if (viewer) {
1635
+ viewer.destroy();
1636
+ }
1637
+ if (cameraPollingInterval) {
1638
+ clearInterval(cameraPollingInterval);
1639
+ }
1640
+ });
1641
+
1642
+ // Expose viewer globally for console debugging
1643
+ window.getViewer = () => viewer;
1644
+ </script>
1645
+ </body>
1646
+ </html>