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.
- package/demo/api-controls-demo.html +1646 -0
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/storysplat-viewer.umd.js +1 -1
- package/dist/storysplat-viewer.umd.js.map +1 -1
- package/dist/types/types/index.d.ts +29 -0
- package/package.json +2 -1
|
@@ -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>
|