@vizor-vr/player 0.2.0 → 0.2.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.
Files changed (184) hide show
  1. package/README.md +4 -0
  2. package/dist/__tests__/autoplay.test.d.ts +2 -0
  3. package/dist/__tests__/autoplay.test.d.ts.map +1 -0
  4. package/dist/__tests__/chapter-logic.test.d.ts +2 -0
  5. package/dist/__tests__/chapter-logic.test.d.ts.map +1 -0
  6. package/dist/__tests__/chapter-manager.test.d.ts +2 -0
  7. package/dist/__tests__/chapter-manager.test.d.ts.map +1 -0
  8. package/dist/__tests__/chapter-menu.test.d.ts +2 -0
  9. package/dist/__tests__/chapter-menu.test.d.ts.map +1 -0
  10. package/dist/__tests__/dom-drm.test.d.ts +2 -0
  11. package/dist/__tests__/dom-drm.test.d.ts.map +1 -0
  12. package/dist/__tests__/drm-gate.test.d.ts +2 -0
  13. package/dist/__tests__/drm-gate.test.d.ts.map +1 -0
  14. package/dist/__tests__/fairplay.test.d.ts +2 -0
  15. package/dist/__tests__/fairplay.test.d.ts.map +1 -0
  16. package/dist/__tests__/hub-controller.test.d.ts +2 -0
  17. package/dist/__tests__/hub-controller.test.d.ts.map +1 -0
  18. package/dist/__tests__/hub-layout.test.d.ts +2 -0
  19. package/dist/__tests__/hub-layout.test.d.ts.map +1 -0
  20. package/dist/__tests__/hub-model.test.d.ts +2 -0
  21. package/dist/__tests__/hub-model.test.d.ts.map +1 -0
  22. package/dist/__tests__/hub-panel-renderer.test.d.ts +2 -0
  23. package/dist/__tests__/hub-panel-renderer.test.d.ts.map +1 -0
  24. package/dist/__tests__/lens-angle.test.d.ts +2 -0
  25. package/dist/__tests__/lens-angle.test.d.ts.map +1 -0
  26. package/dist/__tests__/media-element-drm-gate.test.d.ts +2 -0
  27. package/dist/__tests__/media-element-drm-gate.test.d.ts.map +1 -0
  28. package/dist/__tests__/media-element-gating.test.d.ts +2 -0
  29. package/dist/__tests__/media-element-gating.test.d.ts.map +1 -0
  30. package/dist/__tests__/player-version-lock.test.d.ts +2 -0
  31. package/dist/__tests__/player-version-lock.test.d.ts.map +1 -0
  32. package/dist/__tests__/source-manager-drm.test.d.ts +2 -0
  33. package/dist/__tests__/source-manager-drm.test.d.ts.map +1 -0
  34. package/dist/__tests__/vz-chapter.test.d.ts +2 -0
  35. package/dist/__tests__/vz-chapter.test.d.ts.map +1 -0
  36. package/dist/__tests__/vz-cinema-gating.test.d.ts +2 -0
  37. package/dist/__tests__/vz-cinema-gating.test.d.ts.map +1 -0
  38. package/dist/__tests__/vz-hub-item.test.d.ts +2 -0
  39. package/dist/__tests__/vz-hub-item.test.d.ts.map +1 -0
  40. package/dist/__tests__/vz-tour-gating.test.d.ts +2 -0
  41. package/dist/__tests__/vz-tour-gating.test.d.ts.map +1 -0
  42. package/dist/__tests__/watch-time.test.d.ts +2 -0
  43. package/dist/__tests__/watch-time.test.d.ts.map +1 -0
  44. package/dist/analytics/index.d.ts.map +1 -1
  45. package/dist/annotations/annotation-popup.d.ts.map +1 -1
  46. package/dist/{annotations-C0ugfCn4.js → annotations-Du6ZWrqC.js} +82 -13
  47. package/dist/annotations-Du6ZWrqC.js.map +1 -0
  48. package/dist/captions/caption-renderer.d.ts.map +1 -1
  49. package/dist/{captions-BP0H2E03.js → captions-YvnHAwLY.js} +4 -4
  50. package/dist/captions-YvnHAwLY.js.map +1 -0
  51. package/dist/chapters/chapter-logic.d.ts +47 -0
  52. package/dist/chapters/chapter-logic.d.ts.map +1 -0
  53. package/dist/chapters/chapter-manager.d.ts +42 -0
  54. package/dist/chapters/chapter-manager.d.ts.map +1 -0
  55. package/dist/chapters/chapter-menu.d.ts +24 -0
  56. package/dist/chapters/chapter-menu.d.ts.map +1 -0
  57. package/dist/{collab-BXPomrS0.js → collab-BLpllHtb.js} +20 -14
  58. package/dist/collab-BLpllHtb.js.map +1 -0
  59. package/dist/collaborative/chat-button.d.ts.map +1 -1
  60. package/dist/collaborative/voice-button.d.ts.map +1 -1
  61. package/dist/controls/cc-button.d.ts.map +1 -1
  62. package/dist/controls/compass.d.ts.map +1 -1
  63. package/dist/controls/controls-overlay.d.ts.map +1 -1
  64. package/dist/controls/fullscreen-button.d.ts.map +1 -1
  65. package/dist/controls/icons.d.ts +1 -0
  66. package/dist/controls/icons.d.ts.map +1 -1
  67. package/dist/controls/live-indicator.d.ts.map +1 -1
  68. package/dist/controls/play-button.d.ts.map +1 -1
  69. package/dist/controls/playlist-controls.d.ts.map +1 -1
  70. package/dist/controls/quality-menu.d.ts.map +1 -1
  71. package/dist/controls/seek-bar.d.ts +9 -0
  72. package/dist/controls/seek-bar.d.ts.map +1 -1
  73. package/dist/controls/volume-control.d.ts.map +1 -1
  74. package/dist/controls/vr-button.d.ts.map +1 -1
  75. package/dist/core/base-element.d.ts +8 -0
  76. package/dist/core/base-element.d.ts.map +1 -1
  77. package/dist/core/media-element.d.ts +70 -1
  78. package/dist/core/media-element.d.ts.map +1 -1
  79. package/dist/{dash-adapter-tid9ZyhO.js → dash-adapter-BMpHWcBA.js} +9 -2
  80. package/dist/dash-adapter-BMpHWcBA.js.map +1 -0
  81. package/dist/delivery/__tests__/delivery-client.test.d.ts +2 -0
  82. package/dist/delivery/__tests__/delivery-client.test.d.ts.map +1 -0
  83. package/dist/delivery/__tests__/delivery-hydrate.test.d.ts +2 -0
  84. package/dist/delivery/__tests__/delivery-hydrate.test.d.ts.map +1 -0
  85. package/dist/delivery/delivery-client.d.ts +27 -0
  86. package/dist/delivery/delivery-client.d.ts.map +1 -0
  87. package/dist/delivery/delivery-hydrate.d.ts +18 -0
  88. package/dist/delivery/delivery-hydrate.d.ts.map +1 -0
  89. package/dist/delivery/dom-drm.d.ts +9 -0
  90. package/dist/delivery/dom-drm.d.ts.map +1 -0
  91. package/dist/elements/vz-chapter.d.ts +27 -0
  92. package/dist/elements/vz-chapter.d.ts.map +1 -0
  93. package/dist/elements/vz-cinema.d.ts +20 -0
  94. package/dist/elements/vz-cinema.d.ts.map +1 -1
  95. package/dist/elements/vz-hub-item.d.ts +23 -0
  96. package/dist/elements/vz-hub-item.d.ts.map +1 -0
  97. package/dist/elements/vz-hub.d.ts +57 -0
  98. package/dist/elements/vz-hub.d.ts.map +1 -0
  99. package/dist/elements/vz-playlist.d.ts +14 -0
  100. package/dist/elements/vz-playlist.d.ts.map +1 -1
  101. package/dist/elements/vz-tour.d.ts +16 -0
  102. package/dist/elements/vz-tour.d.ts.map +1 -1
  103. package/dist/hls-adapter-C_a65zia.js +161 -0
  104. package/dist/hls-adapter-C_a65zia.js.map +1 -0
  105. package/dist/hub/hub-controller.d.ts +31 -0
  106. package/dist/hub/hub-controller.d.ts.map +1 -0
  107. package/dist/hub/hub-layout.d.ts +23 -0
  108. package/dist/hub/hub-layout.d.ts.map +1 -0
  109. package/dist/hub/hub-model.d.ts +27 -0
  110. package/dist/hub/hub-model.d.ts.map +1 -0
  111. package/dist/hub/hub-panel-manager.d.ts +16 -0
  112. package/dist/hub/hub-panel-manager.d.ts.map +1 -0
  113. package/dist/hub/hub-panel-renderer.d.ts +26 -0
  114. package/dist/hub/hub-panel-renderer.d.ts.map +1 -0
  115. package/dist/license/__tests__/cache.test.d.ts +2 -0
  116. package/dist/license/__tests__/cache.test.d.ts.map +1 -0
  117. package/dist/license/__tests__/feature-gate.test.d.ts +2 -0
  118. package/dist/license/__tests__/feature-gate.test.d.ts.map +1 -0
  119. package/dist/license/__tests__/format-gate.test.d.ts +2 -0
  120. package/dist/license/__tests__/format-gate.test.d.ts.map +1 -0
  121. package/dist/license/__tests__/license-checker.test.d.ts.map +1 -0
  122. package/dist/license/__tests__/master-key.test.d.ts.map +1 -0
  123. package/dist/license/__tests__/verify-no-ed25519.test.d.ts +2 -0
  124. package/dist/license/__tests__/verify-no-ed25519.test.d.ts.map +1 -0
  125. package/dist/license/__tests__/verify.test.d.ts +2 -0
  126. package/dist/license/__tests__/verify.test.d.ts.map +1 -0
  127. package/dist/license/cache.d.ts +4 -0
  128. package/dist/license/cache.d.ts.map +1 -0
  129. package/dist/license/drm-gate.d.ts +15 -0
  130. package/dist/license/drm-gate.d.ts.map +1 -0
  131. package/dist/license/feature-gate.d.ts +27 -0
  132. package/dist/license/feature-gate.d.ts.map +1 -0
  133. package/dist/license/format-gate.d.ts +23 -0
  134. package/dist/license/format-gate.d.ts.map +1 -0
  135. package/dist/license/license-checker.d.ts.map +1 -1
  136. package/dist/license/master-key.d.ts +11 -7
  137. package/dist/license/master-key.d.ts.map +1 -1
  138. package/dist/license/trusted-key.d.ts +7 -0
  139. package/dist/license/trusted-key.d.ts.map +1 -0
  140. package/dist/license/verify.d.ts +7 -0
  141. package/dist/license/verify.d.ts.map +1 -0
  142. package/dist/{register-CwnmiGEs.js → register-BmZt_300.js} +1482 -163
  143. package/dist/register-BmZt_300.js.map +1 -0
  144. package/dist/register.d.ts.map +1 -1
  145. package/dist/register.js +1 -1
  146. package/dist/renderer/camera-controller.d.ts +1 -1
  147. package/dist/renderer/camera-controller.d.ts.map +1 -1
  148. package/dist/renderer/projections/fisheye-projection.d.ts +4 -0
  149. package/dist/renderer/projections/fisheye-projection.d.ts.map +1 -1
  150. package/dist/renderer/projections/spherical-projection.d.ts +26 -0
  151. package/dist/renderer/projections/spherical-projection.d.ts.map +1 -0
  152. package/dist/renderer/renderer.d.ts.map +1 -1
  153. package/dist/renderer/shaders/fisheye.glsl.d.ts +10 -1
  154. package/dist/renderer/shaders/fisheye.glsl.d.ts.map +1 -1
  155. package/dist/sri.json +8 -8
  156. package/dist/streaming/dash-adapter.d.ts +2 -2
  157. package/dist/streaming/dash-adapter.d.ts.map +1 -1
  158. package/dist/streaming/fairplay.d.ts +21 -0
  159. package/dist/streaming/fairplay.d.ts.map +1 -0
  160. package/dist/streaming/hls-adapter.d.ts +2 -2
  161. package/dist/streaming/hls-adapter.d.ts.map +1 -1
  162. package/dist/streaming/progressive-adapter.d.ts +2 -2
  163. package/dist/streaming/progressive-adapter.d.ts.map +1 -1
  164. package/dist/streaming/source-manager.d.ts +3 -1
  165. package/dist/streaming/source-manager.d.ts.map +1 -1
  166. package/dist/streaming/types.d.ts +9 -3
  167. package/dist/streaming/types.d.ts.map +1 -1
  168. package/dist/styles/controls.d.ts +1 -1
  169. package/dist/styles/controls.d.ts.map +1 -1
  170. package/dist/utils/static-html.d.ts +12 -0
  171. package/dist/utils/static-html.d.ts.map +1 -0
  172. package/dist/vizor.js +4 -4
  173. package/package.json +8 -5
  174. package/dist/__tests__/license-checker.test.d.ts.map +0 -1
  175. package/dist/__tests__/master-key.test.d.ts.map +0 -1
  176. package/dist/annotations-C0ugfCn4.js.map +0 -1
  177. package/dist/captions-BP0H2E03.js.map +0 -1
  178. package/dist/collab-BXPomrS0.js.map +0 -1
  179. package/dist/dash-adapter-tid9ZyhO.js.map +0 -1
  180. package/dist/hls-adapter-BivH46L_.js +0 -97
  181. package/dist/hls-adapter-BivH46L_.js.map +0 -1
  182. package/dist/register-CwnmiGEs.js.map +0 -1
  183. /package/dist/{__tests__ → license/__tests__}/license-checker.test.d.ts +0 -0
  184. /package/dist/{__tests__ → license/__tests__}/master-key.test.d.ts +0 -0
@@ -1,8 +1,8 @@
1
- import { D as DEFAULT_CONFIG, g as getFeaturesForTier, s as sanitizeUrl, e as parseFormat, f as CAMERA_DEFAULTS, S as SPHERE_DEFAULTS, h as clamp, n as normalizeAngle, i as degToRad, j as getFormatMeta, V as VOLUME_STEP, k as SEEK_STEP, m as ANALYTICS_BATCH_INTERVAL, G as GAZE_SAMPLE_INTERVAL, o as GAZE_BATCH_INTERVAL, A as AnnotationManager, M as MEDIA_EVENTS, l as latLonToSpherical, q as sanitizeAnnotationHtml } from "./annotations-C0ugfCn4.js";
1
+ import { D as DEFAULT_CONFIG, g as getFeaturesForTier, s as sanitizeUrl, e as parseFormat, f as CAMERA_DEFAULTS, S as SPHERE_DEFAULTS, h as clamp, n as normalizeAngle, i as degToRad, j as getFormatMeta, V as VOLUME_STEP, k as SEEK_STEP, m as ANALYTICS_BATCH_INTERVAL, G as GAZE_SAMPLE_INTERVAL, o as GAZE_BATCH_INTERVAL, q as sanitizeAnnotationHtml, r as VzFormat, A as AnnotationManager, M as MEDIA_EVENTS, l as latLonToSpherical } from "./annotations-Du6ZWrqC.js";
2
2
  import { b as SphereGeometry, c as MeshBasicMaterial, d as Mesh, e as MathUtils, P as PlaneGeometry, B as BoxGeometry, f as ShaderMaterial, g as FrontSide, h as BufferAttribute, V as Vector2, i as Scene, j as Color, k as PerspectiveCamera, W as WebGLRenderer, l as VideoTexture, m as SRGBColorSpace, L as LinearFilter, T as TextureLoader, C as CanvasTexture, S as SpriteMaterial, a as Sprite, R as Raycaster } from "./three-Bci7aSeI.js";
3
- import { I as ICON_PLAY, e as ICON_PAUSE, f as ICON_VOLUME_HIGH, g as ICON_VOLUME_LOW, h as ICON_VOLUME_MUTED, i as ICON_FULLSCREEN, j as ICON_FULLSCREEN_EXIT, k as ICON_VR, l as ICON_COMPASS, m as ICON_SETTINGS, n as ICON_LIVE_DOT, o as ICON_CC, G as GazeCursorManager, a as ChatPanel, C as ChatButton, d as VoiceManager, V as VoiceButton, b as CollaborativeClient, c as GazeSender, p as ICON_PLAYLIST_PREV, q as ICON_PLAYLIST_NEXT } from "./collab-BXPomrS0.js";
3
+ import { s as setStaticHtml, I as ICON_PLAY, e as ICON_PAUSE, f as ICON_VOLUME_MUTED, g as ICON_VOLUME_HIGH, h as ICON_VOLUME_LOW, i as ICON_FULLSCREEN, j as ICON_FULLSCREEN_EXIT, k as ICON_VR, l as ICON_COMPASS, m as ICON_SETTINGS, n as ICON_LIVE_DOT, o as ICON_CHAPTERS, p as ICON_CC, G as GazeCursorManager, a as ChatPanel, C as ChatButton, d as VoiceManager, V as VoiceButton, b as CollaborativeClient, c as GazeSender, q as ICON_PLAYLIST_PREV, r as ICON_PLAYLIST_NEXT } from "./collab-BLpllHtb.js";
4
4
  import { A as AudioManager } from "./spatial-BeadwNJA.js";
5
- import { C as CaptionRenderer } from "./captions-BP0H2E03.js";
5
+ import { C as CaptionRenderer } from "./captions-YvnHAwLY.js";
6
6
  const HTMLElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
7
7
  };
8
8
  const INITIAL_STATE = {
@@ -207,23 +207,68 @@ function resolveConfig(elementAttrs, metaOverrides) {
207
207
  ...elementAttrs
208
208
  };
209
209
  }
210
- function checkMasterKey(licenseKey) {
210
+ async function verifyLicenseKey(licenseKey, trustedPublicKeyHex) {
211
+ return null;
212
+ }
213
+ async function checkMasterKey(licenseKey) {
214
+ const payload = await verifyLicenseKey();
215
+ if (!payload || payload.tier !== "master") return null;
216
+ return {
217
+ valid: true,
218
+ tier: "enterprise",
219
+ features: getFeaturesForTier("enterprise"),
220
+ domains: payload.domains ?? [],
221
+ expiresAt: payload.expiresAt
222
+ };
223
+ }
224
+ const CACHE_PREFIX = "__vz_lk_";
225
+ const MAX_AGE_MS = 24 * 60 * 60 * 1e3;
226
+ function hashString(input) {
227
+ let h = 5381;
228
+ for (let i = 0; i < input.length; i++) {
229
+ h = h * 33 ^ input.charCodeAt(i);
230
+ }
231
+ return (h >>> 0).toString(36);
232
+ }
233
+ function cacheKeyFor(licenseKey, domain) {
234
+ return CACHE_PREFIX + hashString(`${licenseKey}|${domain}`);
235
+ }
236
+ function integrityTag(licenseKey, domain, result, ts) {
237
+ return hashString(`${licenseKey}|${domain}|${JSON.stringify(result)}|${ts}`);
238
+ }
239
+ function writeLicenseCache(licenseKey, domain, result, now = Date.now()) {
240
+ if (typeof localStorage === "undefined") return;
211
241
  try {
212
- const masterPubKey = true ? "" : "";
213
- if (!masterPubKey) return null;
214
- const base64 = licenseKey.replace(/-/g, "+").replace(/_/g, "/");
215
- const decoded = atob(base64);
216
- const envelope = JSON.parse(decoded);
217
- const payload = JSON.parse(envelope.payload);
218
- if (payload.tier !== "master") return null;
219
- if (payload.publicKey !== masterPubKey) return null;
220
- return {
221
- valid: true,
222
- tier: "enterprise",
223
- features: getFeaturesForTier("enterprise"),
224
- domains: payload.domains,
225
- expiresAt: payload.expiresAt
242
+ const envelope = {
243
+ result,
244
+ ts: now,
245
+ tag: integrityTag(licenseKey, domain, result, now)
226
246
  };
247
+ localStorage.setItem(cacheKeyFor(licenseKey, domain), JSON.stringify(envelope));
248
+ } catch {
249
+ }
250
+ }
251
+ function readLicenseCache(licenseKey, domain, now = Date.now()) {
252
+ if (typeof localStorage === "undefined") return null;
253
+ const key = cacheKeyFor(licenseKey, domain);
254
+ try {
255
+ const raw = localStorage.getItem(key);
256
+ if (!raw) return null;
257
+ const parsed = JSON.parse(raw);
258
+ if (!parsed || typeof parsed.ts !== "number" || typeof parsed.tag !== "string" || !parsed.result) {
259
+ localStorage.removeItem(key);
260
+ return null;
261
+ }
262
+ if (now - parsed.ts > MAX_AGE_MS) {
263
+ localStorage.removeItem(key);
264
+ return null;
265
+ }
266
+ const expected = integrityTag(licenseKey, domain, parsed.result, parsed.ts);
267
+ if (parsed.tag !== expected) {
268
+ localStorage.removeItem(key);
269
+ return null;
270
+ }
271
+ return parsed.result;
227
272
  } catch {
228
273
  return null;
229
274
  }
@@ -232,7 +277,7 @@ const DEFAULT_API_ENDPOINT = "https://api.vizor.io";
232
277
  async function checkLicense(config) {
233
278
  const mode = config.licenseMode ?? (config.licenseKey ? "standalone" : "saas");
234
279
  if (mode === "standalone" && config.licenseKey) {
235
- const masterResult = checkMasterKey(config.licenseKey);
280
+ const masterResult = await checkMasterKey(config.licenseKey);
236
281
  if (masterResult) return masterResult;
237
282
  return checkStandaloneLicense(config.licenseKey, config.apiEndpoint, config.skipPhoneHome);
238
283
  }
@@ -261,56 +306,43 @@ async function checkSaasLicense(apiKey, apiEndpoint) {
261
306
  return freeTierResult("Network error during license validation");
262
307
  }
263
308
  }
264
- const PHONE_HOME_CACHE_KEY = "__vz_lk_cache";
309
+ function currentHostname() {
310
+ return typeof window !== "undefined" ? window.location.hostname : "localhost";
311
+ }
265
312
  async function checkStandaloneLicense(licenseKey, apiEndpoint, skipPhoneHome) {
266
- try {
267
- const decoded = atob(licenseKey.replace(/-/g, "+").replace(/_/g, "/"));
268
- const envelope = JSON.parse(decoded);
269
- const payload = JSON.parse(envelope.payload);
270
- try {
271
- const ed = await import("@noble/ed25519");
272
- const msgBytes = new TextEncoder().encode(envelope.payload);
273
- const sigBytes = hexToBytes(envelope.signature);
274
- const pubBytes = hexToBytes(payload.publicKey);
275
- const valid = await ed.verifyAsync(sigBytes, msgBytes, pubBytes);
276
- if (!valid) {
277
- return freeTierResult("Invalid license signature");
278
- }
279
- } catch {
280
- console.warn("[Vizor] Ed25519 verification unavailable, skipping signature check");
281
- }
282
- if (payload.expiresAt) {
283
- const expires = new Date(payload.expiresAt);
284
- if (expires < /* @__PURE__ */ new Date()) {
285
- return freeTierResult("License expired");
286
- }
287
- }
288
- const currentDomain = typeof window !== "undefined" ? window.location.hostname : "localhost";
289
- if (payload.domains.length > 0 && !matchesDomain(currentDomain, payload.domains)) {
290
- return freeTierResult("Domain not authorized");
291
- }
292
- const tier = payload.tier;
293
- const localResult = {
294
- valid: true,
295
- tier,
296
- features: getFeaturesForTier(tier),
297
- domains: payload.domains,
298
- expiresAt: payload.expiresAt
299
- };
300
- if (!skipPhoneHome && typeof window !== "undefined") {
301
- phoneHome(licenseKey, apiEndpoint, localResult);
302
- }
303
- const cached = getCachedResult();
304
- if (cached) {
305
- return cached;
306
- }
307
- return localResult;
308
- } catch (err) {
309
- console.warn("[Vizor] Standalone license decode failed:", err);
310
- return freeTierResult("Invalid license key format");
313
+ const payload = await verifyLicenseKey();
314
+ if (!payload) {
315
+ return freeTierResult("Invalid or unverifiable license key");
316
+ }
317
+ if (payload.expiresAt) {
318
+ const expires = new Date(payload.expiresAt);
319
+ if (expires < /* @__PURE__ */ new Date()) {
320
+ return freeTierResult("License expired");
321
+ }
322
+ }
323
+ const currentDomain = currentHostname();
324
+ const domains = payload.domains ?? [];
325
+ if (domains.length > 0 && !matchesDomain(currentDomain, domains)) {
326
+ return freeTierResult("Domain not authorized");
327
+ }
328
+ const tier = payload.tier;
329
+ const localResult = {
330
+ valid: true,
331
+ tier,
332
+ features: getFeaturesForTier(tier),
333
+ domains,
334
+ expiresAt: payload.expiresAt
335
+ };
336
+ if (!skipPhoneHome && typeof window !== "undefined") {
337
+ phoneHome(licenseKey, currentDomain, apiEndpoint);
338
+ }
339
+ const cached = readLicenseCache(licenseKey, currentDomain);
340
+ if (cached) {
341
+ return cached;
311
342
  }
343
+ return localResult;
312
344
  }
313
- function phoneHome(licenseKey, apiEndpoint, fallback) {
345
+ function phoneHome(licenseKey, domain, apiEndpoint) {
314
346
  const sessionKey = "__vz_ph_done";
315
347
  if (typeof sessionStorage !== "undefined") {
316
348
  try {
@@ -320,7 +352,6 @@ function phoneHome(licenseKey, apiEndpoint, fallback) {
320
352
  }
321
353
  }
322
354
  const endpoint = apiEndpoint || DEFAULT_API_ENDPOINT;
323
- const domain = typeof window !== "undefined" ? window.location.hostname : "localhost";
324
355
  fetch(`${endpoint}/api/v1/license/validate-standalone`, {
325
356
  method: "POST",
326
357
  headers: { "Content-Type": "application/json" },
@@ -329,31 +360,11 @@ function phoneHome(licenseKey, apiEndpoint, fallback) {
329
360
  if (!res.ok) throw new Error(`Status ${res.status}`);
330
361
  return res.json();
331
362
  }).then((serverResult) => {
332
- try {
333
- localStorage.setItem(
334
- PHONE_HOME_CACHE_KEY,
335
- JSON.stringify({ result: serverResult, ts: Date.now() })
336
- );
337
- } catch {
338
- }
363
+ writeLicenseCache(licenseKey, domain, serverResult);
339
364
  }).catch(() => {
340
365
  console.debug("[Vizor] Phone-home failed, using local license result");
341
366
  });
342
367
  }
343
- function getCachedResult() {
344
- try {
345
- const raw = localStorage.getItem(PHONE_HOME_CACHE_KEY);
346
- if (!raw) return null;
347
- const { result, ts } = JSON.parse(raw);
348
- if (Date.now() - ts > 24 * 60 * 60 * 1e3) {
349
- localStorage.removeItem(PHONE_HOME_CACHE_KEY);
350
- return null;
351
- }
352
- return result;
353
- } catch {
354
- return null;
355
- }
356
- }
357
368
  function freeTierResult(message) {
358
369
  return {
359
370
  valid: false,
@@ -371,13 +382,6 @@ function matchesDomain(hostname, allowedDomains) {
371
382
  return hostname === pattern;
372
383
  });
373
384
  }
374
- function hexToBytes(hex) {
375
- const bytes = new Uint8Array(hex.length / 2);
376
- for (let i = 0; i < bytes.length; i++) {
377
- bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
378
- }
379
- return bytes;
380
- }
381
385
  let observer = null;
382
386
  let guardActive = false;
383
387
  const HARDENED_WATERMARK_CSS = `
@@ -580,8 +584,10 @@ const ATTR_MAP = {
580
584
  author: "author",
581
585
  "author-href": "authorHref",
582
586
  poster: "poster",
587
+ preview: "preview",
583
588
  loop: "loop",
584
589
  muted: "muted",
590
+ autoplay: "autoplay",
585
591
  preload: "preload",
586
592
  fps: "fps",
587
593
  crossorigin: "crossorigin",
@@ -596,6 +602,7 @@ const ATTR_MAP = {
596
602
  "start-lat": "startLat",
597
603
  "start-lon": "startLon",
598
604
  "focal-length": "focalLength",
605
+ "lens-angle": "lensAngle",
599
606
  "min-focal-length": "minFocalLength",
600
607
  "max-focal-length": "maxFocalLength",
601
608
  "license-key": "licenseKey",
@@ -626,6 +633,7 @@ const ATTR_MAP = {
626
633
  const BOOLEAN_ATTRS = /* @__PURE__ */ new Set([
627
634
  "loop",
628
635
  "muted",
636
+ "autoplay",
629
637
  "seekable",
630
638
  "cors-fallback-redirect-confirmation",
631
639
  "hide-compass",
@@ -641,6 +649,7 @@ const NUMBER_ATTRS = /* @__PURE__ */ new Set([
641
649
  "fps",
642
650
  "start-lat",
643
651
  "start-lon",
652
+ "lens-angle",
644
653
  "focal-length",
645
654
  "min-focal-length",
646
655
  "max-focal-length"
@@ -672,6 +681,27 @@ class VzBaseElement extends HTMLElementBase {
672
681
  this.resizeObserver = new ResizeObserver(() => this.onResize());
673
682
  this.resizeObserver.observe(this);
674
683
  this.injectBaseStyles();
684
+ const pending = this.beforeConnect();
685
+ if (pending && typeof pending.then === "function") {
686
+ pending.then(
687
+ () => this.finishConnect(),
688
+ () => this.finishConnect()
689
+ );
690
+ } else {
691
+ this.finishConnect();
692
+ }
693
+ }
694
+ /**
695
+ * Optional async hook run after config/styles are resolved but BEFORE
696
+ * onConnected(). Return a promise to defer onConnected() until it settles
697
+ * (e.g. to inject fetched DOM children); return void (default) to keep the
698
+ * connect path synchronous. Subclasses override.
699
+ */
700
+ beforeConnect() {
701
+ return;
702
+ }
703
+ finishConnect() {
704
+ if (!this.isConnected) return;
675
705
  this.onConnected();
676
706
  this.checkLicenseAsync();
677
707
  }
@@ -1310,6 +1340,7 @@ uniform float uScaleX;
1310
1340
  uniform float uScaleY;
1311
1341
  uniform float uOffsetX;
1312
1342
  uniform float uOffsetY;
1343
+ uniform float uLensFov; // capture-lens angle in RADIANS (WS-F, 120-250 deg)
1313
1344
 
1314
1345
  varying vec3 vWorldPosition;
1315
1346
 
@@ -1328,9 +1359,12 @@ void main() {
1328
1359
  // Azimuthal angle around the hemisphere pole
1329
1360
  float phi = atan(dir.y, dir.x * faceSign);
1330
1361
 
1331
- // Map to circular fisheye UV using equidistant projection
1332
- // r ranges from 0 (pole) to 1 (equator)
1333
- float r = theta / (PI * 0.5);
1362
+ // Map to circular fisheye UV using equidistant projection.
1363
+ // r = 1 at the lens edge (uLensFov/2 from the pole). For lenses wider than
1364
+ // 180 deg the hemisphere uses only the inner part of the circle; for
1365
+ // narrower lenses r can exceed 1 — clamp to the edge (looks better than
1366
+ // holes; decided by visual test).
1367
+ float r = min(theta / (uLensFov * 0.5), 1.0);
1334
1368
 
1335
1369
  // Convert polar (r, phi) to cartesian circle coordinates, centered at (0.5, 0.5)
1336
1370
  vec2 circleUV = vec2(r * cos(phi), r * sin(phi)) * 0.5 + 0.5;
@@ -1347,6 +1381,41 @@ void main() {
1347
1381
  }
1348
1382
  `
1349
1383
  );
1384
+ const sphericalFragmentShader = (
1385
+ /* glsl */
1386
+ `
1387
+ uniform sampler2D map;
1388
+ uniform float uScaleX;
1389
+ uniform float uScaleY;
1390
+ uniform float uOffsetX;
1391
+ uniform float uOffsetY;
1392
+ uniform float uLensFov; // capture-lens angle in RADIANS
1393
+
1394
+ varying vec3 vWorldPosition;
1395
+
1396
+ void main() {
1397
+ vec3 dir = normalize(vWorldPosition);
1398
+
1399
+ // Angle from the forward optical axis (0 straight ahead). The camera
1400
+ // controller's default view (lat 0, lon 0) looks along +x, so that is the
1401
+ // content center for VR180 footage.
1402
+ float theta = acos(clamp(dir.x, -1.0, 1.0));
1403
+
1404
+ // Azimuth around the axis: +z is screen-right at the default view, +y up.
1405
+ float phi = atan(dir.y, dir.z);
1406
+
1407
+ // Equidistant mapping; clamp beyond the lens edge.
1408
+ float r = min(theta / (uLensFov * 0.5), 1.0);
1409
+
1410
+ vec2 circleUV = vec2(r * cos(phi), r * sin(phi)) * 0.5 + 0.5;
1411
+
1412
+ // Stereo region selection (full texture for mono).
1413
+ vec2 uv = vec2(circleUV.x * uScaleX + uOffsetX, circleUV.y * uScaleY + uOffsetY);
1414
+
1415
+ gl_FragColor = texture2D(map, uv);
1416
+ }
1417
+ `
1418
+ );
1350
1419
  function stereoUniforms(stereoMode, eye) {
1351
1420
  switch (stereoMode) {
1352
1421
  case "sbs":
@@ -1374,8 +1443,16 @@ function stereoUniforms(stereoMode, eye) {
1374
1443
  };
1375
1444
  }
1376
1445
  }
1446
+ function clampLensAngle(deg) {
1447
+ if (deg == null || Number.isNaN(deg)) return 180;
1448
+ return Math.min(250, Math.max(120, deg));
1449
+ }
1377
1450
  class FisheyeProjection {
1378
1451
  meshes = [];
1452
+ lensFovRad;
1453
+ constructor(lensAngleDeg) {
1454
+ this.lensFovRad = clampLensAngle(lensAngleDeg) * Math.PI / 180;
1455
+ }
1379
1456
  createMeshes(texture, stereoMode) {
1380
1457
  this.dispose();
1381
1458
  if (stereoMode === "mono") {
@@ -1419,7 +1496,8 @@ class FisheyeProjection {
1419
1496
  uScaleX: { value: uniforms.uScaleX },
1420
1497
  uScaleY: { value: uniforms.uScaleY },
1421
1498
  uOffsetX: { value: uniforms.uOffsetX },
1422
- uOffsetY: { value: uniforms.uOffsetY }
1499
+ uOffsetY: { value: uniforms.uOffsetY },
1500
+ uLensFov: { value: this.lensFovRad }
1423
1501
  },
1424
1502
  vertexShader: fisheyeVertexShader,
1425
1503
  fragmentShader: fisheyeFragmentShader,
@@ -1428,6 +1506,72 @@ class FisheyeProjection {
1428
1506
  return new Mesh(geometry, material);
1429
1507
  }
1430
1508
  }
1509
+ class SphericalProjection {
1510
+ meshes = [];
1511
+ lensFovRad;
1512
+ constructor(lensAngleDeg) {
1513
+ this.lensFovRad = clampLensAngle(lensAngleDeg) * Math.PI / 180;
1514
+ }
1515
+ createMeshes(texture, stereoMode) {
1516
+ this.dispose();
1517
+ if (stereoMode === "mono") {
1518
+ const mesh = this.createSphere(texture, { uScaleX: 1, uScaleY: 1, uOffsetX: 0, uOffsetY: 0 });
1519
+ mesh.layers.set(0);
1520
+ this.meshes = [mesh];
1521
+ return this.meshes;
1522
+ }
1523
+ const effectiveMode = stereoMode === "separate" ? "tb" : stereoMode;
1524
+ const rects = effectiveMode === "sbs" ? {
1525
+ left: { uScaleX: 0.5, uScaleY: 1, uOffsetX: 0, uOffsetY: 0 },
1526
+ right: { uScaleX: 0.5, uScaleY: 1, uOffsetX: 0.5, uOffsetY: 0 }
1527
+ } : {
1528
+ // TB: left eye top half, right eye bottom half (matches fisheye).
1529
+ left: { uScaleX: 1, uScaleY: 0.5, uOffsetX: 0, uOffsetY: 0.5 },
1530
+ right: { uScaleX: 1, uScaleY: 0.5, uOffsetX: 0, uOffsetY: 0 }
1531
+ };
1532
+ const meshL = this.createSphere(texture, rects.left);
1533
+ const meshR = this.createSphere(texture, rects.right);
1534
+ meshL.layers.set(1);
1535
+ meshR.layers.set(2);
1536
+ this.meshes = [meshL, meshR];
1537
+ return this.meshes;
1538
+ }
1539
+ configureCamera(camera) {
1540
+ camera.fov = CAMERA_DEFAULTS.fov;
1541
+ camera.near = CAMERA_DEFAULTS.near;
1542
+ camera.far = CAMERA_DEFAULTS.far;
1543
+ camera.position.set(0, 0, 0);
1544
+ camera.updateProjectionMatrix();
1545
+ }
1546
+ update(_camera, _time) {
1547
+ }
1548
+ dispose() {
1549
+ for (const mesh of this.meshes) {
1550
+ mesh.geometry.dispose();
1551
+ mesh.material.dispose();
1552
+ }
1553
+ this.meshes = [];
1554
+ }
1555
+ createSphere(texture, rect) {
1556
+ const { radius, widthSegments, heightSegments } = SPHERE_DEFAULTS;
1557
+ const geometry = new SphereGeometry(radius, widthSegments, heightSegments);
1558
+ geometry.scale(-1, 1, 1);
1559
+ const material = new ShaderMaterial({
1560
+ uniforms: {
1561
+ map: { value: texture },
1562
+ uScaleX: { value: rect.uScaleX },
1563
+ uScaleY: { value: rect.uScaleY },
1564
+ uOffsetX: { value: rect.uOffsetX },
1565
+ uOffsetY: { value: rect.uOffsetY },
1566
+ uLensFov: { value: this.lensFovRad }
1567
+ },
1568
+ vertexShader: fisheyeVertexShader,
1569
+ fragmentShader: sphericalFragmentShader,
1570
+ side: FrontSide
1571
+ });
1572
+ return new Mesh(geometry, material);
1573
+ }
1574
+ }
1431
1575
  class CameraController {
1432
1576
  camera;
1433
1577
  state;
@@ -1686,7 +1830,10 @@ class VzRenderer {
1686
1830
  minFocalLength: config.minFocalLength,
1687
1831
  maxFocalLength: config.maxFocalLength,
1688
1832
  isPanoramic,
1689
- fovH: formatMeta?.fovH
1833
+ // Spherical (VR180-family) content pans as far as the capture lens
1834
+ // covers — the lens angle IS the horizontal FOV (WS-F). Other formats
1835
+ // keep their registry fovH (fisheye duals are 360).
1836
+ fovH: formatMeta?.projection === "spherical" ? clampLensAngle(this.config.lensAngle) : formatMeta?.fovH
1690
1837
  });
1691
1838
  this.resize();
1692
1839
  }
@@ -1845,8 +1992,9 @@ class VzRenderer {
1845
1992
  createProjection(formatMeta) {
1846
1993
  switch (formatMeta.projection) {
1847
1994
  case "equirect":
1848
- case "spherical":
1849
1995
  return new EquirectProjection(formatMeta.fovH);
1996
+ case "spherical":
1997
+ return new SphericalProjection(this.config.lensAngle);
1850
1998
  case "flat":
1851
1999
  return new FlatProjection();
1852
2000
  case "cubemap":
@@ -1854,7 +2002,7 @@ class VzRenderer {
1854
2002
  case "eac":
1855
2003
  return new EacProjection();
1856
2004
  case "fisheye":
1857
- return new FisheyeProjection();
2005
+ return new FisheyeProjection(this.config.lensAngle);
1858
2006
  default:
1859
2007
  return new EquirectProjection(360);
1860
2008
  }
@@ -1911,7 +2059,7 @@ class ProgressiveAdapter {
1911
2059
  this.videoElement = videoElement;
1912
2060
  this._isAttached = true;
1913
2061
  }
1914
- load(url) {
2062
+ load(url, _drm) {
1915
2063
  if (!this.videoElement) return;
1916
2064
  this.videoElement.src = url;
1917
2065
  }
@@ -1931,11 +2079,11 @@ class ProgressiveAdapter {
1931
2079
  async function createAdapter(type) {
1932
2080
  switch (type) {
1933
2081
  case "hls": {
1934
- const { HlsAdapter } = await import("./hls-adapter-BivH46L_.js");
2082
+ const { HlsAdapter } = await import("./hls-adapter-C_a65zia.js");
1935
2083
  return new HlsAdapter();
1936
2084
  }
1937
2085
  case "dash": {
1938
- const { DashAdapter } = await import("./dash-adapter-tid9ZyhO.js");
2086
+ const { DashAdapter } = await import("./dash-adapter-BMpHWcBA.js");
1939
2087
  return new DashAdapter();
1940
2088
  }
1941
2089
  case "progressive":
@@ -1949,20 +2097,22 @@ class SourceManager {
1949
2097
  state = null;
1950
2098
  events = null;
1951
2099
  sources = [];
2100
+ drm;
1952
2101
  /**
1953
2102
  * Load sources into the video element using the appropriate adapter.
1954
2103
  */
1955
- async loadSources(sources, videoElement, state, events) {
2104
+ async loadSources(sources, videoElement, state, events, drm) {
1956
2105
  this.sources = sources;
1957
2106
  this.videoElement = videoElement;
1958
2107
  this.state = state;
1959
2108
  this.events = events;
2109
+ this.drm = drm;
1960
2110
  if (sources.length === 0) return;
1961
2111
  this.adapter?.destroy();
1962
2112
  const { adapterType, source } = this.selectSource(sources);
1963
2113
  this.adapter = await createAdapter(adapterType);
1964
2114
  this.adapter.attach(videoElement);
1965
- this.adapter.load(source.url);
2115
+ this.adapter.load(source.url, drm);
1966
2116
  this.pollQualityLevels();
1967
2117
  }
1968
2118
  /** Get available quality levels from the current adapter */
@@ -1993,7 +2143,7 @@ class SourceManager {
1993
2143
  if (!this.videoElement || !this.adapter || index >= this.sources.length) return;
1994
2144
  const currentTime = this.videoElement.currentTime;
1995
2145
  const wasPlaying = !this.videoElement.paused;
1996
- this.adapter.load(this.sources[index].url);
2146
+ this.adapter.load(this.sources[index].url, this.drm);
1997
2147
  const onLoaded = () => {
1998
2148
  this.videoElement.currentTime = currentTime;
1999
2149
  if (wasPlaying) this.videoElement.play().catch(() => {
@@ -2220,6 +2370,24 @@ const CONTROLS_CSS = `
2220
2370
  height: 5px;
2221
2371
  }
2222
2372
 
2373
+ .vz-seek-chapters {
2374
+ position: absolute;
2375
+ inset: 0;
2376
+ pointer-events: none;
2377
+ z-index: 3;
2378
+ }
2379
+
2380
+ .vz-seek-chapter-marker {
2381
+ position: absolute;
2382
+ top: 50%;
2383
+ width: 2px;
2384
+ height: 100%;
2385
+ transform: translate(-50%, -50%);
2386
+ background: rgba(0, 0, 0, 0.55);
2387
+ box-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.5);
2388
+ pointer-events: none;
2389
+ }
2390
+
2223
2391
  .vz-seek-buffered {
2224
2392
  position: absolute;
2225
2393
  top: 0;
@@ -2702,6 +2870,74 @@ const CONTROLS_CSS = `
2702
2870
  font-size: 14px;
2703
2871
  }
2704
2872
 
2873
+ /* ------------------------------------------------------------------ */
2874
+ /* Chapters menu */
2875
+ /* ------------------------------------------------------------------ */
2876
+
2877
+ .vz-chapter-menu {
2878
+ position: relative;
2879
+ }
2880
+
2881
+ .vz-chapter-dropdown {
2882
+ position: absolute;
2883
+ bottom: 100%;
2884
+ right: 0;
2885
+ margin-bottom: 8px;
2886
+ background: var(--vz-secondary, rgba(20, 20, 30, 0.95));
2887
+ backdrop-filter: blur(16px) saturate(1.3);
2888
+ -webkit-backdrop-filter: blur(16px) saturate(1.3);
2889
+ border: 1px solid rgba(255, 255, 255, 0.06);
2890
+ border-radius: 8px;
2891
+ padding: 4px 0;
2892
+ min-width: 200px;
2893
+ max-height: 260px;
2894
+ overflow-y: auto;
2895
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.04);
2896
+ display: none;
2897
+ z-index: 20;
2898
+ }
2899
+
2900
+ .vz-chapter-dropdown.open {
2901
+ display: block;
2902
+ }
2903
+
2904
+ .vz-chapter-option {
2905
+ display: flex;
2906
+ align-items: baseline;
2907
+ gap: 10px;
2908
+ padding: 8px 16px;
2909
+ cursor: pointer;
2910
+ font-size: 13px;
2911
+ color: rgba(255, 255, 255, 0.7);
2912
+ white-space: nowrap;
2913
+ transition: color 0.12s, background 0.12s;
2914
+ }
2915
+
2916
+ .vz-chapter-option:hover {
2917
+ background: color-mix(in srgb, var(--vz-primary, #f43f5e) 8%, transparent);
2918
+ color: #fff;
2919
+ }
2920
+
2921
+ .vz-chapter-option.active {
2922
+ color: var(--vz-primary, #f43f5e);
2923
+ }
2924
+
2925
+ .vz-chapter-time {
2926
+ font-variant-numeric: tabular-nums;
2927
+ font-size: 12px;
2928
+ color: rgba(255, 255, 255, 0.45);
2929
+ flex-shrink: 0;
2930
+ }
2931
+
2932
+ .vz-chapter-option.active .vz-chapter-time {
2933
+ color: var(--vz-primary, #f43f5e);
2934
+ }
2935
+
2936
+ .vz-chapter-label {
2937
+ overflow: hidden;
2938
+ text-overflow: ellipsis;
2939
+ }
2940
+
2705
2941
  /* ------------------------------------------------------------------ */
2706
2942
  /* Chat badge */
2707
2943
  /* ------------------------------------------------------------------ */
@@ -2779,7 +3015,7 @@ class ControlsOverlay {
2779
3015
  containerEl.appendChild(this.wrapper);
2780
3016
  this.loadingEl = document.createElement("div");
2781
3017
  this.loadingEl.className = "vz-loading hidden";
2782
- this.loadingEl.innerHTML = '<div class="vz-loading-spinner"></div>';
3018
+ setStaticHtml(this.loadingEl, '<div class="vz-loading-spinner"></div>');
2783
3019
  containerEl.appendChild(this.loadingEl);
2784
3020
  const onActivity = () => this.show();
2785
3021
  containerEl.addEventListener("pointermove", onActivity);
@@ -2883,11 +3119,11 @@ class PlayButton {
2883
3119
  this.button = document.createElement("button");
2884
3120
  this.button.className = "vz-btn vz-play-btn";
2885
3121
  this.button.setAttribute("aria-label", "Play");
2886
- this.button.innerHTML = ICON_PLAY;
3122
+ setStaticHtml(this.button, ICON_PLAY);
2887
3123
  this.bigPlay = document.createElement("button");
2888
3124
  this.bigPlay.className = "vz-big-play";
2889
3125
  this.bigPlay.setAttribute("aria-label", "Play");
2890
- this.bigPlay.innerHTML = ICON_PLAY;
3126
+ setStaticHtml(this.bigPlay, ICON_PLAY);
2891
3127
  containerEl.appendChild(this.bigPlay);
2892
3128
  const toggle = () => {
2893
3129
  if (state.get("paused") || state.get("ended")) {
@@ -2901,14 +3137,14 @@ class PlayButton {
2901
3137
  this.bigPlay.addEventListener("click", toggle);
2902
3138
  this.bigPlay.addEventListener("pointerdown", (e) => e.stopPropagation());
2903
3139
  const unsubPlaying = state.subscribe("playing", (playing) => {
2904
- this.button.innerHTML = playing ? ICON_PAUSE : ICON_PLAY;
3140
+ setStaticHtml(this.button, playing ? ICON_PAUSE : ICON_PLAY);
2905
3141
  this.button.setAttribute("aria-label", playing ? "Pause" : "Play");
2906
3142
  this.bigPlay.classList.toggle("hidden", playing);
2907
3143
  });
2908
3144
  this.unsubscribes.push(unsubPlaying);
2909
3145
  const unsubEnded = state.subscribe("ended", (ended) => {
2910
3146
  if (ended) {
2911
- this.button.innerHTML = ICON_PLAY;
3147
+ setStaticHtml(this.button, ICON_PLAY);
2912
3148
  this.button.setAttribute("aria-label", "Play");
2913
3149
  this.bigPlay.classList.remove("hidden");
2914
3150
  }
@@ -2932,6 +3168,7 @@ class SeekBar {
2932
3168
  tooltip;
2933
3169
  input;
2934
3170
  track;
3171
+ chapterLayer = null;
2935
3172
  unsubscribes = [];
2936
3173
  constructor(state, mediaElement) {
2937
3174
  this.timeDisplay = document.createElement("span");
@@ -3005,6 +3242,27 @@ class SeekBar {
3005
3242
  getElement() {
3006
3243
  return this.container;
3007
3244
  }
3245
+ /**
3246
+ * Render chapter tick markers on the seek track. Markers are purely visual
3247
+ * (pointer-events: none) so they never interfere with the range input; the
3248
+ * native `title` shows the chapter label on hover. Re-render on duration
3249
+ * change by calling again; pass [] to clear.
3250
+ */
3251
+ setChapterMarkers(markers) {
3252
+ if (!this.chapterLayer) {
3253
+ this.chapterLayer = document.createElement("div");
3254
+ this.chapterLayer.className = "vz-seek-chapters";
3255
+ this.track.appendChild(this.chapterLayer);
3256
+ }
3257
+ this.chapterLayer.replaceChildren();
3258
+ for (const m of markers) {
3259
+ const tick = document.createElement("div");
3260
+ tick.className = "vz-seek-chapter-marker";
3261
+ tick.style.left = `${m.position * 100}%`;
3262
+ tick.title = m.title;
3263
+ this.chapterLayer.appendChild(tick);
3264
+ }
3265
+ }
3008
3266
  getTimeDisplay() {
3009
3267
  return this.timeDisplay;
3010
3268
  }
@@ -3041,7 +3299,7 @@ class VolumeControl {
3041
3299
  const muteBtn = document.createElement("button");
3042
3300
  muteBtn.className = "vz-btn vz-mute-btn";
3043
3301
  muteBtn.setAttribute("aria-label", "Mute");
3044
- muteBtn.innerHTML = ICON_VOLUME_HIGH;
3302
+ setStaticHtml(muteBtn, ICON_VOLUME_HIGH);
3045
3303
  muteBtn.addEventListener("click", () => {
3046
3304
  mediaElement.muted = !mediaElement.muted;
3047
3305
  });
@@ -3074,12 +3332,12 @@ class VolumeControl {
3074
3332
  slider.value = String(pct);
3075
3333
  updateSliderTrack(pct);
3076
3334
  if (!state.get("muted")) {
3077
- muteBtn.innerHTML = volume > 0.5 ? ICON_VOLUME_HIGH : volume > 0 ? ICON_VOLUME_LOW : ICON_VOLUME_MUTED;
3335
+ setStaticHtml(muteBtn, volume > 0.5 ? ICON_VOLUME_HIGH : volume > 0 ? ICON_VOLUME_LOW : ICON_VOLUME_MUTED);
3078
3336
  }
3079
3337
  });
3080
3338
  this.unsubscribes.push(unsubVolume);
3081
3339
  const unsubMuted = state.subscribe("muted", (muted) => {
3082
- muteBtn.innerHTML = muted ? ICON_VOLUME_MUTED : state.get("volume") > 0.5 ? ICON_VOLUME_HIGH : ICON_VOLUME_LOW;
3340
+ setStaticHtml(muteBtn, muted ? ICON_VOLUME_MUTED : state.get("volume") > 0.5 ? ICON_VOLUME_HIGH : ICON_VOLUME_LOW);
3083
3341
  muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
3084
3342
  });
3085
3343
  this.unsubscribes.push(unsubMuted);
@@ -3098,12 +3356,12 @@ class FullscreenButton {
3098
3356
  this.button = document.createElement("button");
3099
3357
  this.button.className = "vz-btn vz-fullscreen-btn";
3100
3358
  this.button.setAttribute("aria-label", "Enter fullscreen");
3101
- this.button.innerHTML = ICON_FULLSCREEN;
3359
+ setStaticHtml(this.button, ICON_FULLSCREEN);
3102
3360
  this.button.addEventListener("click", () => {
3103
3361
  mediaElement.toggleFullscreen();
3104
3362
  });
3105
3363
  const unsubFs = state.subscribe("fullscreen", (fs) => {
3106
- this.button.innerHTML = fs ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN;
3364
+ setStaticHtml(this.button, fs ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN);
3107
3365
  this.button.setAttribute("aria-label", fs ? "Exit fullscreen" : "Enter fullscreen");
3108
3366
  });
3109
3367
  this.unsubscribes.push(unsubFs);
@@ -3122,7 +3380,7 @@ class VrButton {
3122
3380
  this.button = document.createElement("button");
3123
3381
  this.button.className = "vz-btn vz-vr-btn";
3124
3382
  this.button.setAttribute("aria-label", "Enter VR");
3125
- this.button.innerHTML = ICON_VR;
3383
+ setStaticHtml(this.button, ICON_VR);
3126
3384
  this.button.style.display = "none";
3127
3385
  this.button.addEventListener("click", () => {
3128
3386
  onToggleXR?.();
@@ -3155,7 +3413,7 @@ class Compass {
3155
3413
  this.button = document.createElement("button");
3156
3414
  this.button.className = "vz-btn vz-compass";
3157
3415
  this.button.setAttribute("aria-label", "Reset to north");
3158
- this.button.innerHTML = ICON_COMPASS;
3416
+ setStaticHtml(this.button, ICON_COMPASS);
3159
3417
  this.button.addEventListener("click", () => {
3160
3418
  const controller = renderer.getCameraController();
3161
3419
  controller.setOrientation(state.get("lat"), 0);
@@ -3186,7 +3444,7 @@ class QualityMenu {
3186
3444
  const btn = document.createElement("button");
3187
3445
  btn.className = "vz-btn vz-quality-btn";
3188
3446
  btn.setAttribute("aria-label", "Quality");
3189
- btn.innerHTML = ICON_SETTINGS;
3447
+ setStaticHtml(btn, ICON_SETTINGS);
3190
3448
  this.dropdown = document.createElement("div");
3191
3449
  this.dropdown.className = "vz-quality-dropdown";
3192
3450
  this.container.appendChild(btn);
@@ -3220,10 +3478,13 @@ class QualityMenu {
3220
3478
  renderOptions(state, sourceManager) {
3221
3479
  const levels = state.get("availableQualities");
3222
3480
  const currentQuality = state.get("currentQuality");
3223
- this.dropdown.innerHTML = "";
3481
+ this.dropdown.replaceChildren();
3224
3482
  const autoOption = document.createElement("div");
3225
3483
  autoOption.className = `vz-quality-option ${currentQuality === "auto" ? "active" : ""}`;
3226
- autoOption.innerHTML = `<span class="vz-quality-check">${currentQuality === "auto" ? "✓" : ""}</span>Auto`;
3484
+ const autoCheck = document.createElement("span");
3485
+ autoCheck.className = "vz-quality-check";
3486
+ autoCheck.textContent = currentQuality === "auto" ? "✓" : "";
3487
+ autoOption.append(autoCheck, document.createTextNode("Auto"));
3227
3488
  autoOption.addEventListener("click", () => {
3228
3489
  sourceManager.setQualityLevel(-1);
3229
3490
  this.isOpen = false;
@@ -3234,7 +3495,10 @@ class QualityMenu {
3234
3495
  const option = document.createElement("div");
3235
3496
  const isActive = level.label === currentQuality;
3236
3497
  option.className = `vz-quality-option ${isActive ? "active" : ""}`;
3237
- option.innerHTML = `<span class="vz-quality-check">${isActive ? "" : ""}</span>${level.label}`;
3498
+ const check = document.createElement("span");
3499
+ check.className = "vz-quality-check";
3500
+ check.textContent = isActive ? "✓" : "";
3501
+ option.append(check, document.createTextNode(level.label));
3238
3502
  option.addEventListener("click", () => {
3239
3503
  sourceManager.setQualityLevel(index);
3240
3504
  this.isOpen = false;
@@ -3485,7 +3749,7 @@ class LiveIndicator {
3485
3749
  this.container.title = "Jump to live";
3486
3750
  this.dotEl = document.createElement("span");
3487
3751
  this.dotEl.className = "vz-live-dot at-edge";
3488
- this.dotEl.innerHTML = ICON_LIVE_DOT;
3752
+ setStaticHtml(this.dotEl, ICON_LIVE_DOT);
3489
3753
  this.labelEl = document.createElement("span");
3490
3754
  this.labelEl.className = "vz-live-label";
3491
3755
  this.labelEl.textContent = "LIVE";
@@ -3708,16 +3972,55 @@ function initAnalytics(state, events, options) {
3708
3972
  apiEndpoint: options.apiEndpoint,
3709
3973
  apiKey: options.apiKey
3710
3974
  });
3975
+ const WATCH_TIME_REPORT_SECONDS = 60;
3976
+ let playStartMs = null;
3977
+ let accumulatedMs = 0;
3978
+ const reportWatchTime = () => {
3979
+ if (playStartMs != null) {
3980
+ accumulatedMs += Date.now() - playStartMs;
3981
+ playStartMs = state.get("playing") ? Date.now() : null;
3982
+ }
3983
+ const seconds = Math.round(accumulatedMs / 1e3);
3984
+ if (seconds >= 1) {
3985
+ const quality = state.get("currentQuality");
3986
+ collector.track("watch_time", { seconds, ...quality ? { quality } : {} });
3987
+ accumulatedMs = 0;
3988
+ }
3989
+ };
3990
+ const watchTimer = setInterval(() => {
3991
+ const liveMs = accumulatedMs + (playStartMs != null ? Date.now() - playStartMs : 0);
3992
+ if (liveMs >= WATCH_TIME_REPORT_SECONDS * 1e3) reportWatchTime();
3993
+ }, 1e4);
3994
+ cleanups.push(() => clearInterval(watchTimer));
3995
+ if (typeof window !== "undefined") {
3996
+ const watchVisibilityHandler = () => {
3997
+ if (document.visibilityState === "hidden") {
3998
+ reportWatchTime();
3999
+ void collector.flush();
4000
+ }
4001
+ };
4002
+ window.addEventListener("visibilitychange", watchVisibilityHandler);
4003
+ cleanups.push(() => window.removeEventListener("visibilitychange", watchVisibilityHandler));
4004
+ }
3711
4005
  cleanups.push(
3712
4006
  state.subscribe("playing", (playing) => {
3713
4007
  collector.track(playing ? "play" : "pause");
4008
+ if (playing) {
4009
+ playStartMs = Date.now();
4010
+ } else {
4011
+ reportWatchTime();
4012
+ }
3714
4013
  })
3715
4014
  );
3716
4015
  cleanups.push(
3717
4016
  state.subscribe("ended", (ended) => {
3718
- if (ended) collector.track("ended");
4017
+ if (ended) {
4018
+ collector.track("ended");
4019
+ reportWatchTime();
4020
+ }
3719
4021
  })
3720
4022
  );
4023
+ cleanups.push(() => reportWatchTime());
3721
4024
  cleanups.push(
3722
4025
  state.subscribe("seeking", (seeking) => {
3723
4026
  if (seeking) collector.track("seek", { time: state.get("currentTime") });
@@ -3799,21 +4102,313 @@ function initAnalytics(state, events, options) {
3799
4102
  }
3800
4103
  };
3801
4104
  }
3802
- class CcButton {
4105
+ function trimTrailingSlash(value) {
4106
+ return value.replace(/\/+$/, "");
4107
+ }
4108
+ async function getJson(url, apiKey, fetchImpl) {
4109
+ try {
4110
+ const headers = {};
4111
+ if (apiKey) headers["x-api-key"] = apiKey;
4112
+ const res = await fetchImpl(url, { headers });
4113
+ if (!res.ok) return null;
4114
+ return await res.json();
4115
+ } catch {
4116
+ return null;
4117
+ }
4118
+ }
4119
+ async function fetchDeliveryContent(opts, fetchImpl = fetch) {
4120
+ const url = `${trimTrailingSlash(opts.apiEndpoint)}/api/v1/delivery/content/${encodeURIComponent(
4121
+ opts.contentId
4122
+ )}`;
4123
+ return getJson(url, opts.apiKey, fetchImpl);
4124
+ }
4125
+ function vttDataUri(vtt) {
4126
+ return `data:text/vtt;charset=utf-8,${encodeURIComponent(vtt)}`;
4127
+ }
4128
+ function injectSources(host, config) {
4129
+ for (const source of config.sources) {
4130
+ const el = document.createElement("source");
4131
+ el.setAttribute("src", source.url);
4132
+ el.setAttribute("type", source.type);
4133
+ if (source.quality) el.setAttribute("quality", source.quality);
4134
+ if (source.fps != null) el.setAttribute("fps", String(source.fps));
4135
+ if (source.format) el.setAttribute("format", source.format);
4136
+ if (source.audioFormat) el.setAttribute("audio-format", source.audioFormat);
4137
+ host.appendChild(el);
4138
+ }
4139
+ }
4140
+ function injectAnnotations(host, config) {
4141
+ for (const a of config.annotations) {
4142
+ const el = document.createElement("vz-annotation");
4143
+ el.setAttribute("lat", String(a.lat));
4144
+ el.setAttribute("lon", String(a.lon));
4145
+ el.setAttribute("title", a.title);
4146
+ if (a.icon) el.setAttribute("icon", a.icon);
4147
+ if (a.timeStart != null) el.setAttribute("time-start", String(a.timeStart));
4148
+ if (a.timeEnd != null) el.setAttribute("time-end", String(a.timeEnd));
4149
+ el.setAttribute("sort-order", String(a.sortOrder));
4150
+ if (a.htmlContent) el.innerHTML = sanitizeAnnotationHtml(a.htmlContent);
4151
+ host.appendChild(el);
4152
+ }
4153
+ }
4154
+ function injectChapters(host, config) {
4155
+ for (const c of config.chapters) {
4156
+ const el = document.createElement("vz-chapter");
4157
+ el.setAttribute("start", String(c.startTime));
4158
+ el.setAttribute("title", c.title);
4159
+ el.setAttribute("sort-order", String(c.sortOrder));
4160
+ el.setAttribute("chapter-id", c.id);
4161
+ host.appendChild(el);
4162
+ }
4163
+ }
4164
+ function injectCaptions(host, config) {
4165
+ for (const c of config.captions) {
4166
+ const el = document.createElement("track");
4167
+ el.setAttribute("kind", "captions");
4168
+ el.setAttribute("srclang", c.srclang);
4169
+ el.setAttribute("label", c.label);
4170
+ el.setAttribute("src", vttDataUri(c.vttContent));
4171
+ host.appendChild(el);
4172
+ }
4173
+ }
4174
+ function applyDeliveryConfigToDom(host, config) {
4175
+ if (config.lensAngle != null && !host.hasAttribute("lens-angle")) {
4176
+ host.setAttribute("lens-angle", String(config.lensAngle));
4177
+ }
4178
+ if (config.previewUrl && !host.hasAttribute("preview")) {
4179
+ host.setAttribute("preview", config.previewUrl);
4180
+ }
4181
+ injectSources(host, config);
4182
+ injectAnnotations(host, config);
4183
+ injectCaptions(host, config);
4184
+ injectChapters(host, config);
4185
+ }
4186
+ const MONO_BY_PROJECTION = {
4187
+ flat: VzFormat.MONO_FLAT,
4188
+ equirect: VzFormat.MONO_360,
4189
+ cubemap: VzFormat.MONO_CUBEMAP,
4190
+ eac: VzFormat.MONO_EAC,
4191
+ fisheye: VzFormat.MONO_FISHEYE
4192
+ };
4193
+ function monoEquivalent(meta) {
4194
+ if (meta.fovH === 180) return void 0;
4195
+ return MONO_BY_PROJECTION[meta.projection];
4196
+ }
4197
+ function resolveFormatGate(format, features) {
4198
+ const meta = getFormatMeta(format);
4199
+ if (!meta) return { action: "allow" };
4200
+ const is180 = meta.fovH === 180;
4201
+ const isStereo = meta.stereoMode !== "mono";
4202
+ if (is180 && !features.formats180) return { action: "block", requires: "formats180" };
4203
+ if (isStereo && !features.stereoFormats) {
4204
+ const mono = monoEquivalent(meta);
4205
+ return mono ? { action: "downgrade", to: mono } : { action: "block", requires: "stereoFormats" };
4206
+ }
4207
+ return { action: "allow" };
4208
+ }
4209
+ function resolveFeatureGate(_feature, enabled) {
4210
+ return enabled ? { action: "allow" } : { action: "degrade" };
4211
+ }
4212
+ function resolveDrmGate(hasDrmConfig, drmFeatureEnabled) {
4213
+ if (!hasDrmConfig) return { action: "none" };
4214
+ return drmFeatureEnabled ? { action: "allow" } : { action: "block" };
4215
+ }
4216
+ function parseDomDrmConfig(host) {
4217
+ const keyId = host.getAttribute("drm-key-id");
4218
+ const widevine = host.getAttribute("drm-widevine-url");
4219
+ const playready = host.getAttribute("drm-playready-url");
4220
+ const fairplay = host.getAttribute("drm-fairplay-url");
4221
+ if (!keyId || !widevine || !playready || !fairplay) return null;
4222
+ return {
4223
+ scheme: "cbcs",
4224
+ keyId,
4225
+ licenseUrls: { widevine, playready, fairplay },
4226
+ fairplayCertUrl: host.getAttribute("drm-fairplay-cert-url")
4227
+ };
4228
+ }
4229
+ function normalizeChapters(raw) {
4230
+ const withDefaults = raw.map((c, i) => ({
4231
+ id: c.id ?? "",
4232
+ title: c.title,
4233
+ startTime: c.startTime,
4234
+ sortOrder: c.sortOrder ?? i
4235
+ }));
4236
+ return sortChapters(withDefaults).map((c, i) => ({
4237
+ ...c,
4238
+ id: c.id || `chapter-${i}`
4239
+ }));
4240
+ }
4241
+ function sortChapters(chapters) {
4242
+ return [...chapters].sort((a, b) => a.startTime - b.startTime);
4243
+ }
4244
+ function resolveActiveChapter(chapters, currentTime) {
4245
+ let active = null;
4246
+ for (const c of sortChapters(chapters)) {
4247
+ if (c.startTime <= currentTime) active = c;
4248
+ else break;
4249
+ }
4250
+ return active;
4251
+ }
4252
+ function chapterMarkerPositions(chapters, duration) {
4253
+ if (!(duration > 0)) return [];
4254
+ return sortChapters(chapters).map((c) => ({
4255
+ id: c.id,
4256
+ title: c.title,
4257
+ startTime: c.startTime,
4258
+ position: Math.max(0, Math.min(1, c.startTime / duration))
4259
+ }));
4260
+ }
4261
+ function formatTimestamp(seconds) {
4262
+ if (isNaN(seconds) || !isFinite(seconds) || seconds < 0) return "0:00";
4263
+ const s = Math.floor(seconds);
4264
+ const h = Math.floor(s / 3600);
4265
+ const m = Math.floor(s % 3600 / 60);
4266
+ const sec = s % 60;
4267
+ if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
4268
+ return `${m}:${String(sec).padStart(2, "0")}`;
4269
+ }
4270
+ class ChapterMenu {
3803
4271
  container;
3804
- dropdown;
3805
4272
  btn;
3806
- captionRenderer;
3807
- unsubscribes = [];
4273
+ dropdown;
4274
+ chapters;
4275
+ onSelect;
4276
+ activeId = null;
3808
4277
  isOpen = false;
3809
- constructor(state, captionRenderer) {
3810
- this.captionRenderer = captionRenderer;
4278
+ unsubscribes = [];
4279
+ constructor(chapters, onSelect) {
4280
+ this.chapters = chapters;
4281
+ this.onSelect = onSelect;
4282
+ this.container = document.createElement("div");
4283
+ this.container.className = "vz-chapter-menu";
4284
+ this.btn = document.createElement("button");
4285
+ this.btn.className = "vz-btn vz-chapter-btn";
4286
+ this.btn.setAttribute("aria-label", "Chapters");
4287
+ this.btn.title = "Chapters";
4288
+ setStaticHtml(this.btn, ICON_CHAPTERS);
4289
+ this.dropdown = document.createElement("div");
4290
+ this.dropdown.className = "vz-chapter-dropdown";
4291
+ this.container.appendChild(this.btn);
4292
+ this.container.appendChild(this.dropdown);
4293
+ this.btn.addEventListener("click", (e) => {
4294
+ e.stopPropagation();
4295
+ this.isOpen = !this.isOpen;
4296
+ this.dropdown.classList.toggle("open", this.isOpen);
4297
+ });
4298
+ const onDocClick = () => {
4299
+ if (this.isOpen) {
4300
+ this.isOpen = false;
4301
+ this.dropdown.classList.remove("open");
4302
+ }
4303
+ };
4304
+ document.addEventListener("click", onDocClick);
4305
+ this.unsubscribes.push(() => document.removeEventListener("click", onDocClick));
4306
+ this.renderOptions();
4307
+ }
4308
+ getElement() {
4309
+ return this.container;
4310
+ }
4311
+ /** Highlight the chapter with the given id (or clear when null). */
4312
+ setActive(id) {
4313
+ this.activeId = id;
4314
+ const rows = this.dropdown.querySelectorAll(".vz-chapter-option");
4315
+ rows.forEach((row) => {
4316
+ row.classList.toggle("active", row.dataset.chapterId === id);
4317
+ });
4318
+ }
4319
+ dispose() {
4320
+ for (const unsub of this.unsubscribes) unsub();
4321
+ this.unsubscribes = [];
4322
+ this.container.remove();
4323
+ }
4324
+ renderOptions() {
4325
+ this.dropdown.replaceChildren();
4326
+ for (const chapter of this.chapters) {
4327
+ const row = document.createElement("div");
4328
+ row.className = "vz-chapter-option";
4329
+ row.dataset.chapterId = chapter.id;
4330
+ if (chapter.id === this.activeId) row.classList.add("active");
4331
+ const time = document.createElement("span");
4332
+ time.className = "vz-chapter-time";
4333
+ time.textContent = formatTimestamp(chapter.startTime);
4334
+ const label = document.createElement("span");
4335
+ label.className = "vz-chapter-label";
4336
+ label.textContent = chapter.title;
4337
+ row.appendChild(time);
4338
+ row.appendChild(label);
4339
+ row.addEventListener("click", (e) => {
4340
+ e.stopPropagation();
4341
+ this.isOpen = false;
4342
+ this.dropdown.classList.remove("open");
4343
+ this.onSelect(chapter.startTime);
4344
+ });
4345
+ this.dropdown.appendChild(row);
4346
+ }
4347
+ }
4348
+ }
4349
+ class ChapterManager {
4350
+ chapters;
4351
+ state;
4352
+ events;
4353
+ seek;
4354
+ seekBar;
4355
+ menu = null;
4356
+ activeId = null;
4357
+ unsubscribes = [];
4358
+ constructor(opts) {
4359
+ this.chapters = opts.chapters;
4360
+ this.state = opts.state;
4361
+ this.events = opts.events;
4362
+ this.seek = opts.seek;
4363
+ this.seekBar = opts.seekBar ?? null;
4364
+ if (opts.controlsOverlay) {
4365
+ this.menu = new ChapterMenu(this.chapters, (t) => this.seek(t));
4366
+ opts.controlsOverlay.addControl(this.menu.getElement(), "right");
4367
+ }
4368
+ this.renderMarkers(this.state.get("duration"));
4369
+ this.unsubscribes.push(this.state.subscribe("duration", (d) => this.renderMarkers(d)));
4370
+ this.updateActive(this.state.get("currentTime"));
4371
+ this.unsubscribes.push(this.state.subscribe("currentTime", (t) => this.updateActive(t)));
4372
+ }
4373
+ renderMarkers(duration) {
4374
+ this.seekBar?.setChapterMarkers(chapterMarkerPositions(this.chapters, duration));
4375
+ }
4376
+ updateActive(currentTime) {
4377
+ const active = resolveActiveChapter(this.chapters, currentTime);
4378
+ const id = active?.id ?? null;
4379
+ if (id === this.activeId) return;
4380
+ this.activeId = id;
4381
+ this.menu?.setActive(id);
4382
+ if (active) {
4383
+ this.events.emit("vz-chapter-change", {
4384
+ chapterId: active.id,
4385
+ title: active.title,
4386
+ startTime: active.startTime
4387
+ });
4388
+ }
4389
+ }
4390
+ dispose() {
4391
+ for (const u of this.unsubscribes) u();
4392
+ this.unsubscribes = [];
4393
+ this.menu?.dispose();
4394
+ this.menu = null;
4395
+ }
4396
+ }
4397
+ class CcButton {
4398
+ container;
4399
+ dropdown;
4400
+ btn;
4401
+ captionRenderer;
4402
+ unsubscribes = [];
4403
+ isOpen = false;
4404
+ constructor(state, captionRenderer) {
4405
+ this.captionRenderer = captionRenderer;
3811
4406
  this.container = document.createElement("div");
3812
4407
  this.container.className = "vz-cc-menu";
3813
4408
  this.btn = document.createElement("button");
3814
4409
  this.btn.className = "vz-btn vz-cc-btn";
3815
4410
  this.btn.setAttribute("aria-label", "Captions");
3816
- this.btn.innerHTML = ICON_CC;
4411
+ setStaticHtml(this.btn, ICON_CC);
3817
4412
  this.dropdown = document.createElement("div");
3818
4413
  this.dropdown.className = "vz-cc-dropdown";
3819
4414
  this.container.appendChild(this.btn);
@@ -3854,12 +4449,15 @@ class CcButton {
3854
4449
  }
3855
4450
  }
3856
4451
  renderOptions() {
3857
- this.dropdown.innerHTML = "";
4452
+ this.dropdown.replaceChildren();
3858
4453
  const tracks = this.captionRenderer.getTracks();
3859
4454
  const activeIndex = this.captionRenderer.getActiveTrackIndex();
3860
4455
  const offOption = document.createElement("div");
3861
4456
  offOption.className = `vz-cc-option ${activeIndex === -1 ? "active" : ""}`;
3862
- offOption.innerHTML = `<span class="vz-cc-check">${activeIndex === -1 ? "" : ""}</span>Off`;
4457
+ const offCheck = document.createElement("span");
4458
+ offCheck.className = "vz-cc-check";
4459
+ offCheck.textContent = activeIndex === -1 ? "✓" : "";
4460
+ offOption.append(offCheck, document.createTextNode("Off"));
3863
4461
  offOption.addEventListener("click", () => {
3864
4462
  this.captionRenderer.setActiveTrack(-1);
3865
4463
  this.isOpen = false;
@@ -3886,6 +4484,21 @@ class CcButton {
3886
4484
  });
3887
4485
  }
3888
4486
  }
4487
+ function applyAutoplayPolicy(video, config) {
4488
+ if (!config.autoplay) return;
4489
+ if (!video.muted) {
4490
+ console.info("[Vizor] autoplay requires muted playback — muting (browser policy)");
4491
+ video.muted = true;
4492
+ }
4493
+ let attempted = false;
4494
+ video.addEventListener("canplay", () => {
4495
+ if (attempted) return;
4496
+ attempted = true;
4497
+ const p = video.play();
4498
+ if (p && typeof p.catch === "function") p.catch(() => {
4499
+ });
4500
+ });
4501
+ }
3889
4502
  class VzMediaElement extends VzBaseElement {
3890
4503
  renderer;
3891
4504
  videoElement;
@@ -3909,6 +4522,8 @@ class VzMediaElement extends VzBaseElement {
3909
4522
  liveIndicator = null;
3910
4523
  analyticsHandle = null;
3911
4524
  annotationManager = null;
4525
+ chapterManager = null;
4526
+ pendingChapters = [];
3912
4527
  captionRenderer = null;
3913
4528
  ccButton = null;
3914
4529
  collabClient = null;
@@ -3918,6 +4533,30 @@ class VzMediaElement extends VzBaseElement {
3918
4533
  chatButton = null;
3919
4534
  voiceManager = null;
3920
4535
  voiceButton = null;
4536
+ drmConfig = null;
4537
+ deferredDrmSources = [];
4538
+ /**
4539
+ * A content-id embed (`<vz-video content-id api-key>` with no DOM `<source>`)
4540
+ * fetches its config from the delivery API and injects the equivalent
4541
+ * `<source>`/`<vz-annotation>`/`<track>` DOM children before onConnected() runs
4542
+ * — so authored content actually reaches the viewer. A DOM-authored embed
4543
+ * (any `<source>` child) wins and skips the fetch entirely.
4544
+ */
4545
+ beforeConnect() {
4546
+ const { contentId, apiKey } = this.config;
4547
+ if (!contentId || !apiKey) return;
4548
+ if (this.querySelector("source")) return;
4549
+ return this.hydrateFromDeliveryApi(contentId, apiKey);
4550
+ }
4551
+ async hydrateFromDeliveryApi(contentId, apiKey) {
4552
+ const apiEndpoint = this.config.apiEndpoint ?? window.location.origin;
4553
+ const config = await fetchDeliveryContent({ apiEndpoint, contentId, apiKey });
4554
+ if (!config) return;
4555
+ if (!this.hasAttribute("format")) this.config.format = config.format;
4556
+ if (!this.config.poster && config.poster) this.config.poster = config.poster;
4557
+ applyDeliveryConfigToDom(this, config);
4558
+ if (config.drm) this.drmConfig = config.drm;
4559
+ }
3921
4560
  onConnected() {
3922
4561
  this.containerEl = document.createElement("div");
3923
4562
  this.containerEl.className = "vz-container";
@@ -3931,6 +4570,16 @@ class VzMediaElement extends VzBaseElement {
3931
4570
  });
3932
4571
  posterEl.classList.add("hidden");
3933
4572
  });
4573
+ if (this.config.preview) {
4574
+ posterEl.addEventListener("mouseenter", () => {
4575
+ if (this.hasFeature("animatedPreviews")) {
4576
+ posterEl.style.backgroundImage = `url(${this.config.preview})`;
4577
+ }
4578
+ });
4579
+ posterEl.addEventListener("mouseleave", () => {
4580
+ posterEl.style.backgroundImage = `url(${this.config.poster})`;
4581
+ });
4582
+ }
3934
4583
  this.shadow.appendChild(posterEl);
3935
4584
  }
3936
4585
  this.videoElement = this.createVideoElement();
@@ -3947,14 +4596,8 @@ class VzMediaElement extends VzBaseElement {
3947
4596
  }
3948
4597
  });
3949
4598
  this.sourceManager = new SourceManager();
4599
+ this.drmConfig = this.drmConfig ?? parseDomDrmConfig(this);
3950
4600
  this.loadSources();
3951
- if (this.config.audioFormat === "ambix") {
3952
- this.audioManager = new AudioManager(this.state, this.events);
3953
- this.audioManager.init(this.videoElement, "ambix").catch((err) => {
3954
- console.warn("[Vizor] Audio init error:", err);
3955
- });
3956
- this.renderer.setAudioManager(this.audioManager);
3957
- }
3958
4601
  this.cleanupFullscreen = onFullscreenChange(() => {
3959
4602
  const fs = isFullscreen();
3960
4603
  this.state.set("fullscreen", fs);
@@ -4032,6 +4675,7 @@ class VzMediaElement extends VzBaseElement {
4032
4675
  }
4033
4676
  this.initAnnotations();
4034
4677
  this.initCaptions();
4678
+ this.initChapters();
4035
4679
  this.initCollab();
4036
4680
  this.keyboardShortcuts = new KeyboardShortcuts(this, this.state, this, this.captionRenderer, this.chatPanel, this.voiceButton);
4037
4681
  this.renderer.start();
@@ -4060,6 +4704,8 @@ class VzMediaElement extends VzBaseElement {
4060
4704
  this.analyticsHandle = null;
4061
4705
  this.annotationManager?.dispose();
4062
4706
  this.annotationManager = null;
4707
+ this.chapterManager?.dispose();
4708
+ this.chapterManager = null;
4063
4709
  this.ccButton?.dispose();
4064
4710
  this.ccButton = null;
4065
4711
  this.captionRenderer?.dispose();
@@ -4083,16 +4729,127 @@ class VzMediaElement extends VzBaseElement {
4083
4729
  this.videoElement.removeAttribute("src");
4084
4730
  this.videoElement.load();
4085
4731
  this.renderer.dispose();
4732
+ this.drmConfig = null;
4733
+ this.deferredDrmSources = [];
4086
4734
  this.onMediaDisconnected();
4087
4735
  }
4088
4736
  onResize() {
4089
4737
  this.renderer?.resize();
4090
4738
  }
4091
- /** After license resolves, enforce theme color gating */
4739
+ /** After license resolves, enforce theme + format/feature tier gating */
4092
4740
  onLicenseResolved() {
4093
4741
  if (this.controlsOverlay && this.license) {
4094
4742
  this.controlsOverlay.updateTheme(this.config, this.license);
4095
4743
  }
4744
+ this.applyFormatGate();
4745
+ this.applyWebxrGate();
4746
+ this.applySpatialAudioGate();
4747
+ this.applyChaptersGate();
4748
+ this.applyDrmGate();
4749
+ }
4750
+ /**
4751
+ * Manual chapters are a paid feature. Once the license is known, wire the
4752
+ * chapter UI (seek-bar markers + menu) and active-chapter tracking that fires
4753
+ * `vz-chapter-change`, but only when the tier includes chapters. Unlicensed
4754
+ * tiers degrade gracefully: no markers, no menu, no event (the video plays
4755
+ * unaffected).
4756
+ */
4757
+ applyChaptersGate() {
4758
+ if (this.chapterManager || this.pendingChapters.length === 0) return;
4759
+ if (resolveFeatureGate("chapters", this.hasFeature("chapters")).action !== "allow") return;
4760
+ this.chapterManager = new ChapterManager({
4761
+ chapters: this.pendingChapters,
4762
+ state: this.state,
4763
+ events: this.events,
4764
+ seek: (t) => this.seek(t),
4765
+ seekBar: this.seekBar,
4766
+ controlsOverlay: this.controlsOverlay
4767
+ });
4768
+ }
4769
+ /**
4770
+ * WebXR (Enter-VR) is a paid feature. Once the license is known, if the tier
4771
+ * doesn't include it, remove the VR button so it can't be used — flat/pointer
4772
+ * viewing continues unaffected (graceful downgrade). Licensed users keep the
4773
+ * button, which reveals itself only when the device reports XR support.
4774
+ */
4775
+ applyWebxrGate() {
4776
+ if (!this.license) return;
4777
+ if (resolveFeatureGate("webxr", this.hasFeature("webxr")).action === "allow") return;
4778
+ this.vrButton?.dispose();
4779
+ this.vrButton?.getElement().remove();
4780
+ this.vrButton = null;
4781
+ }
4782
+ /**
4783
+ * Spatial (ambisonic) audio is a paid feature. When the source is authored as
4784
+ * ambix and the tier includes it, wire up the AudioManager here (license is
4785
+ * now known). When the tier lacks it, we do nothing — the hidden <video>'s
4786
+ * own audio track plays as ordinary stereo, the graceful fallback.
4787
+ */
4788
+ applySpatialAudioGate() {
4789
+ if (this.config.audioFormat !== "ambix") return;
4790
+ if (this.audioManager) return;
4791
+ if (resolveFeatureGate("spatialAudio", this.hasFeature("spatialAudio")).action !== "allow") {
4792
+ return;
4793
+ }
4794
+ this.audioManager = new AudioManager(this.state, this.events);
4795
+ this.audioManager.init(this.videoElement, "ambix").catch((err) => {
4796
+ console.warn("[Vizor] Audio init error:", err);
4797
+ });
4798
+ this.renderer.setAudioManager(this.audioManager);
4799
+ }
4800
+ /**
4801
+ * Enforce format tier gating once the license is known. A stereo format the
4802
+ * tier doesn't include is downgraded to its mono equivalent (re-projecting if
4803
+ * the renderer already set up the ungated one); a format with no fallback (a
4804
+ * 180 half-sphere) is blocked behind an upgrade overlay. Prevents free users
4805
+ * from silently receiving paid formats.
4806
+ */
4807
+ applyFormatGate() {
4808
+ if (!this.license || !this.config.format) return;
4809
+ const decision = resolveFormatGate(this.config.format, this.license.features);
4810
+ if (decision.action === "downgrade") {
4811
+ this.config.format = decision.to;
4812
+ if (this.renderer && this.videoElement.readyState >= 2) {
4813
+ this.renderer.setupVideo(this.videoElement, decision.to);
4814
+ }
4815
+ } else if (decision.action === "block") {
4816
+ this.suppressAutoSetup = true;
4817
+ this.videoElement.pause();
4818
+ this.showFormatUpgradeOverlay(decision.requires);
4819
+ }
4820
+ }
4821
+ showFormatUpgradeOverlay(requires) {
4822
+ const label = requires === "formats180" ? "180° video" : "Stereoscopic 3D";
4823
+ this.showUpgradeOverlay("vz-format-gate", `${label} playback requires a paid Vizor plan.`);
4824
+ }
4825
+ showUpgradeOverlay(className, message) {
4826
+ if (!this.containerEl || this.shadow.querySelector("." + className)) return;
4827
+ const overlay = document.createElement("div");
4828
+ overlay.className = className;
4829
+ overlay.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:1rem;background:rgba(0,0,0,0.85);color:#fff;z-index:10;font:500 14px/1.5 system-ui,-apple-system,sans-serif;";
4830
+ overlay.textContent = message;
4831
+ this.containerEl.appendChild(overlay);
4832
+ }
4833
+ /**
4834
+ * DRM assets are encrypted-only (no plain fallback). Once the license is known:
4835
+ * a tier WITHOUT `drm` is blocked behind an upgrade overlay (and NO license
4836
+ * request is made); a licensed tier performs the deferred encrypted source load
4837
+ * with the DRM context (license URLs + api-key) so the adapter can drive EME.
4838
+ * EME/CDM errors during playback surface via the adapter's existing error events.
4839
+ */
4840
+ applyDrmGate() {
4841
+ const decision = resolveDrmGate(this.drmConfig !== null, this.hasFeature("drm"));
4842
+ if (decision.action === "none") return;
4843
+ if (decision.action === "block") {
4844
+ this.videoElement.pause();
4845
+ this.showUpgradeOverlay("vz-drm-gate", "DRM-protected playback requires a Vizor Enterprise plan.");
4846
+ return;
4847
+ }
4848
+ const drm = { config: this.drmConfig, apiKey: this.config.apiKey };
4849
+ this.sourceManager.loadSources(this.deferredDrmSources, this.videoElement, this.state, this.events, drm).catch((err) => {
4850
+ console.error("[Vizor] DRM source loading error:", err);
4851
+ this.events.emit("vz-error", { code: "DRM_LOAD_ERROR", message: String(err) });
4852
+ });
4096
4853
  }
4097
4854
  onAttributeChanged(name, _oldVal, _newVal) {
4098
4855
  if (name === "format" && this.config.format && this.renderer) {
@@ -4178,6 +4935,23 @@ class VzMediaElement extends VzBaseElement {
4178
4935
  this.annotationManager?.update(time);
4179
4936
  });
4180
4937
  }
4938
+ /**
4939
+ * Parse `<vz-chapter>` children into a normalized pending list. The chapter UI
4940
+ * (markers/menu/event) is wired later in applyChaptersGate() once the license
4941
+ * is known, since chapters are tier-gated.
4942
+ */
4943
+ initChapters() {
4944
+ const chapterEls = this.querySelectorAll("vz-chapter");
4945
+ if (chapterEls.length === 0) return;
4946
+ const raw = [];
4947
+ chapterEls.forEach((el) => {
4948
+ const vzEl = el;
4949
+ if (typeof vzEl.getData === "function") {
4950
+ raw.push(vzEl.getData());
4951
+ }
4952
+ });
4953
+ this.pendingChapters = normalizeChapters(raw);
4954
+ }
4181
4955
  /** Initialize collaborative viewing if collab attributes are set */
4182
4956
  initCollab() {
4183
4957
  const { collabServer, collabRoom, collabRole, collabDisplayName } = this.config;
@@ -4296,6 +5070,7 @@ class VzMediaElement extends VzBaseElement {
4296
5070
  video.preload = this.config.preload === "none" ? "metadata" : this.config.preload;
4297
5071
  video.loop = this.config.loop;
4298
5072
  video.muted = this.config.muted;
5073
+ applyAutoplayPolicy(video, this.config);
4299
5074
  video.style.display = "none";
4300
5075
  video.addEventListener("error", () => {
4301
5076
  const err = video.error;
@@ -4336,6 +5111,10 @@ class VzMediaElement extends VzBaseElement {
4336
5111
  }
4337
5112
  }
4338
5113
  if (sources.length === 0) return;
5114
+ if (this.drmConfig) {
5115
+ this.deferredDrmSources = sources;
5116
+ return;
5117
+ }
4339
5118
  this.sourceManager.loadSources(sources, this.videoElement, this.state, this.events).catch((err) => {
4340
5119
  console.error("[Vizor] Source loading error:", err);
4341
5120
  this.events.emit("vz-error", { code: "SOURCE_LOAD_ERROR", message: String(err) });
@@ -4480,8 +5259,8 @@ function registerImg() {
4480
5259
  const HOTSPOT_DISTANCE = 200;
4481
5260
  const HOTSPOT_SCALE_X = 30;
4482
5261
  const HOTSPOT_SCALE_Y = 15;
4483
- const CANVAS_W = 256;
4484
- const CANVAS_H = 128;
5262
+ const CANVAS_W$1 = 256;
5263
+ const CANVAS_H$1 = 128;
4485
5264
  class HotspotRenderer {
4486
5265
  sprites = [];
4487
5266
  scene;
@@ -4516,22 +5295,22 @@ class HotspotRenderer {
4516
5295
  }
4517
5296
  createSprite(portal) {
4518
5297
  const canvas = document.createElement("canvas");
4519
- canvas.width = CANVAS_W;
4520
- canvas.height = CANVAS_H;
5298
+ canvas.width = CANVAS_W$1;
5299
+ canvas.height = CANVAS_H$1;
4521
5300
  const ctx = canvas.getContext("2d");
4522
5301
  const pad = 8;
4523
5302
  ctx.fillStyle = "rgba(0, 160, 255, 0.85)";
4524
5303
  ctx.beginPath();
4525
- this.roundRect(ctx, pad, pad, CANVAS_W - pad * 2, CANVAS_H - pad * 2, 16);
5304
+ this.roundRect(ctx, pad, pad, CANVAS_W$1 - pad * 2, CANVAS_H$1 - pad * 2, 16);
4526
5305
  ctx.fill();
4527
5306
  const label = portal.title ?? portal.to;
4528
5307
  ctx.fillStyle = "#fff";
4529
5308
  ctx.font = "bold 24px sans-serif";
4530
5309
  ctx.textAlign = "center";
4531
5310
  ctx.textBaseline = "middle";
4532
- ctx.fillText(label, CANVAS_W / 2, CANVAS_H / 2 - 4, CANVAS_W - 32);
5311
+ ctx.fillText(label, CANVAS_W$1 / 2, CANVAS_H$1 / 2 - 4, CANVAS_W$1 - 32);
4533
5312
  ctx.font = "16px sans-serif";
4534
- ctx.fillText("→", CANVAS_W / 2, CANVAS_H / 2 + 20);
5313
+ ctx.fillText("→", CANVAS_W$1 / 2, CANVAS_H$1 / 2 + 20);
4535
5314
  const texture = new CanvasTexture(canvas);
4536
5315
  texture.minFilter = LinearFilter;
4537
5316
  texture.magFilter = LinearFilter;
@@ -4645,6 +5424,12 @@ class VzTourElement extends VzBaseElement {
4645
5424
  xrManager = null;
4646
5425
  cleanupFullscreen = null;
4647
5426
  loader = new TextureLoader();
5427
+ /**
5428
+ * True once the license resolves without the `tours` feature. In that state
5429
+ * the tour degrades to a single static panorama (the start probe): portal
5430
+ * navigation between probes is disabled.
5431
+ */
5432
+ toursLocked = false;
4648
5433
  onConnected() {
4649
5434
  this.containerEl = document.createElement("div");
4650
5435
  this.containerEl.className = "vz-container";
@@ -4719,6 +5504,26 @@ class VzTourElement extends VzBaseElement {
4719
5504
  onResize() {
4720
5505
  this.renderer?.resize();
4721
5506
  }
5507
+ /**
5508
+ * Interactive tours are a paid feature, and WebXR (Enter-VR) is gated
5509
+ * separately. Once the license resolves, enforce both.
5510
+ *
5511
+ * - tours not licensed → graceful downgrade to a single static panorama: the
5512
+ * start probe stays visible but portal navigation is disabled (existing
5513
+ * portals cleared, future ones suppressed).
5514
+ * - webxr not licensed → remove the VR button (flat viewing continues).
5515
+ */
5516
+ onLicenseResolved() {
5517
+ if (resolveFeatureGate("webxr", this.hasFeature("webxr")).action !== "allow") {
5518
+ this.vrButton?.dispose();
5519
+ this.vrButton?.getElement().remove();
5520
+ this.vrButton = null;
5521
+ }
5522
+ if (resolveFeatureGate("tours", this.hasFeature("tours")).action !== "allow") {
5523
+ this.toursLocked = true;
5524
+ this.hotspotManager?.clear();
5525
+ }
5526
+ }
4722
5527
  /** Navigate to a specific probe by ID */
4723
5528
  async navigateTo(probeId) {
4724
5529
  const probe = this.probes.get(probeId);
@@ -4743,7 +5548,9 @@ class VzTourElement extends VzBaseElement {
4743
5548
  if (probe.rotationY !== void 0) {
4744
5549
  this.renderer.getCameraController().setOrientation(0, probe.rotationY);
4745
5550
  }
4746
- this.hotspotManager?.setPortals(probe.portals);
5551
+ if (!this.toursLocked) {
5552
+ this.hotspotManager?.setPortals(probe.portals);
5553
+ }
4747
5554
  this.state.batch({
4748
5555
  currentProbeId: probeId
4749
5556
  });
@@ -4959,6 +5766,9 @@ class CinemaProjection {
4959
5766
  }
4960
5767
  class VzCinemaElement extends VzMediaElement {
4961
5768
  cinemaProjection = null;
5769
+ videoDataReady = false;
5770
+ /** Guard so the projection (cinema or flat fallback) is only built once. */
5771
+ projectionSetup = false;
4962
5772
  static get observedAttributes() {
4963
5773
  return [
4964
5774
  ...super.observedAttributes,
@@ -4973,15 +5783,51 @@ class VzCinemaElement extends VzMediaElement {
4973
5783
  onMediaConnected() {
4974
5784
  this.suppressAutoSetup = true;
4975
5785
  if (this.videoElement.readyState >= 2) {
4976
- this.setupCinemaProjection();
5786
+ this.videoDataReady = true;
5787
+ this.maybeSetupProjection();
4977
5788
  } else {
4978
5789
  this.videoElement.addEventListener("loadeddata", () => {
4979
- this.setupCinemaProjection();
5790
+ this.videoDataReady = true;
5791
+ this.maybeSetupProjection();
4980
5792
  }, { once: true });
4981
5793
  }
4982
5794
  }
5795
+ /**
5796
+ * Cinema is a paid feature. The base class runs its own gates (format, webxr,
5797
+ * spatial audio) here; we add the cinema gate and (re)attempt projection
5798
+ * setup now that the license is known.
5799
+ */
5800
+ onLicenseResolved() {
5801
+ super.onLicenseResolved();
5802
+ this.maybeSetupProjection();
5803
+ }
5804
+ /**
5805
+ * Build the projection once BOTH the video data is ready AND the license has
5806
+ * resolved. Licensed → the cinema room projection. Unlicensed → graceful
5807
+ * downgrade to flat (mono) playback of the same clip.
5808
+ */
5809
+ maybeSetupProjection() {
5810
+ if (this.projectionSetup) return;
5811
+ if (!this.videoDataReady) return;
5812
+ if (!this.license) return;
5813
+ this.projectionSetup = true;
5814
+ if (resolveFeatureGate("cinema", this.hasFeature("cinema")).action === "allow") {
5815
+ this.setupCinemaProjection();
5816
+ } else {
5817
+ this.setupFlatFallback();
5818
+ }
5819
+ }
5820
+ /**
5821
+ * Graceful downgrade when the tier lacks cinema: render the clip as an
5822
+ * ordinary flat video instead of projecting it onto a screen inside a room.
5823
+ */
5824
+ setupFlatFallback() {
5825
+ this.renderer.setupVideo(this.videoElement, VzFormat.MONO_FLAT);
5826
+ }
4983
5827
  onMediaDisconnected() {
4984
5828
  this.cinemaProjection = null;
5829
+ this.projectionSetup = false;
5830
+ this.videoDataReady = false;
4985
5831
  }
4986
5832
  setupCinemaProjection() {
4987
5833
  this.cinemaProjection = new CinemaProjection({
@@ -5089,7 +5935,7 @@ class PlaylistControls {
5089
5935
  this.injectedRight = controlsRight;
5090
5936
  this.prevBtn = document.createElement("button");
5091
5937
  this.prevBtn.className = "vz-btn vz-playlist-prev";
5092
- this.prevBtn.innerHTML = ICON_PLAYLIST_PREV;
5938
+ setStaticHtml(this.prevBtn, ICON_PLAYLIST_PREV);
5093
5939
  this.prevBtn.title = "Previous";
5094
5940
  this.prevBtn.addEventListener("click", (e) => {
5095
5941
  e.stopPropagation();
@@ -5097,7 +5943,7 @@ class PlaylistControls {
5097
5943
  });
5098
5944
  this.nextBtn = document.createElement("button");
5099
5945
  this.nextBtn.className = "vz-btn vz-playlist-next";
5100
- this.nextBtn.innerHTML = ICON_PLAYLIST_NEXT;
5946
+ setStaticHtml(this.nextBtn, ICON_PLAYLIST_NEXT);
5101
5947
  this.nextBtn.title = "Next";
5102
5948
  this.nextBtn.addEventListener("click", (e) => {
5103
5949
  e.stopPropagation();
@@ -5119,7 +5965,7 @@ class PlaylistControls {
5119
5965
  if (onTogglePanel && controlsRight) {
5120
5966
  this.panelBtn = document.createElement("button");
5121
5967
  this.panelBtn.className = "vz-btn vz-playlist-panel-toggle";
5122
- this.panelBtn.innerHTML = ICON_PLAYLIST_LIST;
5968
+ setStaticHtml(this.panelBtn, ICON_PLAYLIST_LIST);
5123
5969
  this.panelBtn.title = "Playlist";
5124
5970
  this.panelBtn.addEventListener("click", (e) => {
5125
5971
  e.stopPropagation();
@@ -5166,7 +6012,7 @@ class PlaylistPanel {
5166
6012
  title.textContent = "Playlist";
5167
6013
  const closeBtn = document.createElement("button");
5168
6014
  closeBtn.className = "vz-playlist-panel-close";
5169
- closeBtn.innerHTML = "&times;";
6015
+ closeBtn.textContent = "×";
5170
6016
  closeBtn.title = "Close";
5171
6017
  closeBtn.addEventListener("click", (e) => {
5172
6018
  e.stopPropagation();
@@ -5217,7 +6063,7 @@ class PlaylistPanel {
5217
6063
  this.panel.remove();
5218
6064
  }
5219
6065
  renderItems(items, activeIndex) {
5220
- this.listEl.innerHTML = "";
6066
+ this.listEl.replaceChildren();
5221
6067
  for (let i = 0; i < items.length; i++) {
5222
6068
  const item = items[i];
5223
6069
  const row = document.createElement("div");
@@ -5392,6 +6238,14 @@ class VzPlaylistElement extends HTMLElementBase {
5392
6238
  playlistPanel = null;
5393
6239
  endedHandler = null;
5394
6240
  connected = false;
6241
+ /**
6242
+ * True once a child player reports a license tier without the `playlist`
6243
+ * feature. The queue degrades to playing only the first item as a single
6244
+ * clip: auto-advance and prev/next navigation are disabled and the multi-item
6245
+ * controls/panel are removed.
6246
+ */
6247
+ playlistLocked = false;
6248
+ licenseListener = null;
5395
6249
  constructor() {
5396
6250
  super();
5397
6251
  this.shadow = this.attachShadow({ mode: "open" });
@@ -5437,6 +6291,15 @@ class VzPlaylistElement extends HTMLElementBase {
5437
6291
  const slot = document.createElement("slot");
5438
6292
  wrapper.appendChild(slot);
5439
6293
  this.parseItems();
6294
+ this.licenseListener = (e) => {
6295
+ const detail = e.detail;
6296
+ if (!detail?.tier) return;
6297
+ const features = getFeaturesForTier(detail.tier);
6298
+ if (resolveFeatureGate("playlist", features.playlist).action !== "allow") {
6299
+ this.applyPlaylistLock();
6300
+ }
6301
+ };
6302
+ this.addEventListener("vz-license", this.licenseListener);
5440
6303
  if (this.panelMode === "expandable") {
5441
6304
  this.playlistPanel = new PlaylistPanel(
5442
6305
  this.shadow,
@@ -5459,6 +6322,10 @@ class VzPlaylistElement extends HTMLElementBase {
5459
6322
  disconnectedCallback() {
5460
6323
  this.connected = false;
5461
6324
  this.detachEndedListener();
6325
+ if (this.licenseListener) {
6326
+ this.removeEventListener("vz-license", this.licenseListener);
6327
+ this.licenseListener = null;
6328
+ }
5462
6329
  this.playlistControls?.dispose();
5463
6330
  this.playlistControls = null;
5464
6331
  this.playlistPanel?.dispose();
@@ -5470,6 +6337,7 @@ class VzPlaylistElement extends HTMLElementBase {
5470
6337
  /** Navigate to item at index */
5471
6338
  goTo(index) {
5472
6339
  if (index < 0 || index >= this.items.length) return;
6340
+ if (this.playlistLocked && index !== 0) return;
5473
6341
  const prevIndex = this.currentIndex;
5474
6342
  this.currentIndex = index;
5475
6343
  for (let i = 0; i < this.items.length; i++) {
@@ -5513,6 +6381,7 @@ class VzPlaylistElement extends HTMLElementBase {
5513
6381
  }
5514
6382
  /** Go to next item */
5515
6383
  next() {
6384
+ if (this.playlistLocked) return;
5516
6385
  if (this.currentIndex < this.items.length - 1) {
5517
6386
  this.goTo(this.currentIndex + 1);
5518
6387
  } else if (this.loopPlaylist) {
@@ -5521,12 +6390,27 @@ class VzPlaylistElement extends HTMLElementBase {
5521
6390
  }
5522
6391
  /** Go to previous item */
5523
6392
  previous() {
6393
+ if (this.playlistLocked) return;
5524
6394
  if (this.currentIndex > 0) {
5525
6395
  this.goTo(this.currentIndex - 1);
5526
6396
  } else if (this.loopPlaylist) {
5527
6397
  this.goTo(this.items.length - 1);
5528
6398
  }
5529
6399
  }
6400
+ /**
6401
+ * Graceful downgrade when the tier lacks the playlist feature: keep the first
6402
+ * item playing as a single clip and disable everything queue-specific —
6403
+ * auto-advance, prev/next, the injected controls, and the side panel.
6404
+ */
6405
+ applyPlaylistLock() {
6406
+ if (this.playlistLocked) return;
6407
+ this.playlistLocked = true;
6408
+ this.detachEndedListener();
6409
+ this.playlistControls?.dispose();
6410
+ this.playlistControls = null;
6411
+ this.playlistPanel?.dispose();
6412
+ this.playlistPanel = null;
6413
+ }
5530
6414
  /** Shuffle the playlist order */
5531
6415
  shuffle() {
5532
6416
  for (let i = this.items.length - 1; i > 0; i--) {
@@ -5604,8 +6488,9 @@ class VzPlaylistElement extends HTMLElementBase {
5604
6488
  }
5605
6489
  injectControls() {
5606
6490
  this.playlistControls?.dispose();
6491
+ this.playlistControls = null;
6492
+ if (this.playlistLocked) return;
5607
6493
  if (this.items.length <= 1) {
5608
- this.playlistControls = null;
5609
6494
  return;
5610
6495
  }
5611
6496
  const activeEl = this.items[this.currentIndex]?.element;
@@ -5688,17 +6573,451 @@ function registerAnnotation() {
5688
6573
  customElements.define("vz-annotation", VzAnnotationElement);
5689
6574
  }
5690
6575
  }
6576
+ class VzChapterElement extends HTMLElementBase {
6577
+ static get observedAttributes() {
6578
+ return ["start", "title", "sort-order", "chapter-id"];
6579
+ }
6580
+ /** Parse this element's attributes into a RawChapter. */
6581
+ getData() {
6582
+ const startTime = this.hasAttribute("start") ? parseFloat(this.getAttribute("start")) : 0;
6583
+ const title = this.getAttribute("title") ?? "";
6584
+ const sortOrder = this.hasAttribute("sort-order") ? parseInt(this.getAttribute("sort-order"), 10) : void 0;
6585
+ const id = this.getAttribute("chapter-id") ?? void 0;
6586
+ return {
6587
+ id,
6588
+ title,
6589
+ startTime: isNaN(startTime) ? 0 : startTime,
6590
+ sortOrder: sortOrder != null && !isNaN(sortOrder) ? sortOrder : void 0
6591
+ };
6592
+ }
6593
+ }
6594
+ function registerChapter() {
6595
+ if (typeof customElements === "undefined") return;
6596
+ if (!customElements.get("vz-chapter")) {
6597
+ customElements.define("vz-chapter", VzChapterElement);
6598
+ }
6599
+ }
6600
+ class VzHubItemElement extends HTMLElementBase {
6601
+ static get observedAttributes() {
6602
+ return ["src", "poster", "title", "format", "content-id"];
6603
+ }
6604
+ /** Parse this element's attributes into a RawHubItem. */
6605
+ getData() {
6606
+ return {
6607
+ src: this.getAttribute("src"),
6608
+ poster: this.getAttribute("poster"),
6609
+ title: this.getAttribute("title"),
6610
+ format: this.getAttribute("format"),
6611
+ contentId: this.getAttribute("content-id")
6612
+ };
6613
+ }
6614
+ }
6615
+ function registerHubItem() {
6616
+ if (typeof customElements === "undefined") return;
6617
+ if (!customElements.get("vz-hub-item")) {
6618
+ customElements.define("vz-hub-item", VzHubItemElement);
6619
+ }
6620
+ }
6621
+ function normalizeLon(lon) {
6622
+ return ((lon + 180) % 360 + 360) % 360 - 180;
6623
+ }
6624
+ function hubPanelLayout(count, opts = {}) {
6625
+ if (count <= 0) return [];
6626
+ const spacing = opts.spacingDeg ?? 30;
6627
+ const lat = opts.lat ?? 0;
6628
+ const span = (count - 1) * spacing;
6629
+ if (span > 330) {
6630
+ const step = 360 / count;
6631
+ return Array.from({ length: count }, (_, i) => ({ lat, lon: normalizeLon(i * step - 180) }));
6632
+ }
6633
+ const start = -span / 2;
6634
+ return Array.from({ length: count }, (_, i) => ({ lat, lon: normalizeLon(start + i * spacing) }));
6635
+ }
6636
+ const PANEL_DISTANCE = 200;
6637
+ const PANEL_SCALE_X = 60;
6638
+ const PANEL_SCALE_Y = 34;
6639
+ const CANVAS_W = 320;
6640
+ const CANVAS_H = 180;
6641
+ class HubPanelRenderer {
6642
+ sprites = [];
6643
+ posterTextures = [];
6644
+ scene;
6645
+ loader = new TextureLoader();
6646
+ constructor(scene) {
6647
+ this.scene = scene;
6648
+ }
6649
+ /** Build a panel sprite per item and add them to the scene. */
6650
+ setItems(items) {
6651
+ this.clear();
6652
+ const positions = hubPanelLayout(items.length);
6653
+ items.forEach((item, index) => {
6654
+ const sprite = this.createPanelSprite(item, positions[index], index);
6655
+ this.sprites.push(sprite);
6656
+ this.scene.add(sprite);
6657
+ });
6658
+ }
6659
+ /** Sprites for raycasting (consumed by HotspotInteraction). */
6660
+ getSprites() {
6661
+ return this.sprites;
6662
+ }
6663
+ clear() {
6664
+ for (const sprite of this.sprites) {
6665
+ this.scene.remove(sprite);
6666
+ sprite.material.map?.dispose();
6667
+ sprite.material.dispose();
6668
+ }
6669
+ this.sprites = [];
6670
+ for (const tex of this.posterTextures) tex.dispose();
6671
+ this.posterTextures = [];
6672
+ }
6673
+ dispose() {
6674
+ this.clear();
6675
+ }
6676
+ createPanelSprite(item, pos, index) {
6677
+ const canvas = document.createElement("canvas");
6678
+ canvas.width = CANVAS_W;
6679
+ canvas.height = CANVAS_H;
6680
+ const ctx = canvas.getContext("2d");
6681
+ ctx.fillStyle = "rgba(15, 15, 25, 0.9)";
6682
+ ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
6683
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.25)";
6684
+ ctx.lineWidth = 3;
6685
+ ctx.strokeRect(2, 2, CANVAS_W - 4, CANVAS_H - 4);
6686
+ ctx.fillStyle = "#fff";
6687
+ ctx.font = "bold 26px sans-serif";
6688
+ ctx.textAlign = "center";
6689
+ ctx.textBaseline = "middle";
6690
+ ctx.fillText(item.title || "Play", CANVAS_W / 2, CANVAS_H / 2, CANVAS_W - 40);
6691
+ const texture = new CanvasTexture(canvas);
6692
+ texture.minFilter = LinearFilter;
6693
+ texture.magFilter = LinearFilter;
6694
+ const material = new SpriteMaterial({ map: texture, depthTest: false, sizeAttenuation: true });
6695
+ const sprite = new Sprite(material);
6696
+ sprite.userData.portalTo = String(index);
6697
+ sprite.userData.hubIndex = index;
6698
+ const { phi, theta } = latLonToSpherical(pos.lat, pos.lon);
6699
+ sprite.position.set(
6700
+ PANEL_DISTANCE * Math.sin(phi) * Math.cos(theta),
6701
+ PANEL_DISTANCE * Math.cos(phi),
6702
+ PANEL_DISTANCE * Math.sin(phi) * Math.sin(theta)
6703
+ );
6704
+ sprite.scale.set(PANEL_SCALE_X, PANEL_SCALE_Y, 1);
6705
+ if (item.poster) {
6706
+ this.loader.load(
6707
+ item.poster,
6708
+ (tex) => {
6709
+ tex.colorSpace = SRGBColorSpace;
6710
+ tex.minFilter = LinearFilter;
6711
+ tex.magFilter = LinearFilter;
6712
+ material.map?.dispose();
6713
+ material.map = tex;
6714
+ material.needsUpdate = true;
6715
+ this.posterTextures.push(tex);
6716
+ },
6717
+ void 0,
6718
+ () => {
6719
+ }
6720
+ );
6721
+ }
6722
+ return sprite;
6723
+ }
6724
+ }
6725
+ class HubPanelManager {
6726
+ renderer;
6727
+ interaction;
6728
+ constructor(scene, camera, domElement, onSelect) {
6729
+ this.renderer = new HubPanelRenderer(scene);
6730
+ this.interaction = new HotspotInteraction(
6731
+ camera,
6732
+ domElement,
6733
+ () => this.renderer.getSprites(),
6734
+ (portalTo) => onSelect(Number(portalTo))
6735
+ );
6736
+ this.interaction.attach();
6737
+ }
6738
+ setItems(items) {
6739
+ this.renderer.setItems(items);
6740
+ }
6741
+ clear() {
6742
+ this.renderer.clear();
6743
+ }
6744
+ dispose() {
6745
+ this.interaction.dispose();
6746
+ this.renderer.dispose();
6747
+ }
6748
+ }
6749
+ class HubController {
6750
+ items;
6751
+ events;
6752
+ onShowItem;
6753
+ onShowLobby;
6754
+ activeIndex = -1;
6755
+ constructor(opts) {
6756
+ this.items = opts.items;
6757
+ this.events = opts.events;
6758
+ this.onShowItem = opts.onShowItem;
6759
+ this.onShowLobby = opts.onShowLobby;
6760
+ }
6761
+ isInLobby() {
6762
+ return this.activeIndex === -1;
6763
+ }
6764
+ getActiveIndex() {
6765
+ return this.activeIndex;
6766
+ }
6767
+ /** Select an item by index: play it and fire vz-hub-select. No-op if invalid or already active. */
6768
+ select(index) {
6769
+ if (index < 0 || index >= this.items.length) return;
6770
+ if (index === this.activeIndex) return;
6771
+ this.activeIndex = index;
6772
+ const item = this.items[index];
6773
+ this.onShowItem(item, index);
6774
+ this.events.emit("vz-hub-select", {
6775
+ id: item.id,
6776
+ src: item.src,
6777
+ title: item.title,
6778
+ index
6779
+ });
6780
+ }
6781
+ /** Return to the lobby. No-op if already there. */
6782
+ back() {
6783
+ if (this.activeIndex === -1) return;
6784
+ this.activeIndex = -1;
6785
+ this.onShowLobby();
6786
+ }
6787
+ }
6788
+ function isImageSource(src) {
6789
+ const clean = (src.split("?")[0] ?? "").split("#")[0] ?? "";
6790
+ return /\.(jpe?g|png|webp|avif|gif|bmp)$/i.test(clean);
6791
+ }
6792
+ function normalizeHubItems(raw) {
6793
+ const items = [];
6794
+ raw.forEach((r, i) => {
6795
+ const src = r.src?.trim();
6796
+ if (!src) return;
6797
+ items.push({
6798
+ id: r.contentId?.trim() || `item-${i}`,
6799
+ src,
6800
+ poster: r.poster?.trim() || null,
6801
+ title: r.title ?? "",
6802
+ format: r.format?.trim() || "MONO_360"
6803
+ });
6804
+ });
6805
+ return items;
6806
+ }
6807
+ class VzHubElement extends VzBaseElement {
6808
+ renderer;
6809
+ containerEl;
6810
+ items = [];
6811
+ panelManager = null;
6812
+ controller = null;
6813
+ controlsOverlay = null;
6814
+ compass = null;
6815
+ fullscreenButton = null;
6816
+ vrButton = null;
6817
+ xrManager = null;
6818
+ cleanupFullscreen = null;
6819
+ loader = new TextureLoader();
6820
+ bgTexture = null;
6821
+ playingEl = null;
6822
+ hubLocked = false;
6823
+ onConnected() {
6824
+ this.containerEl = document.createElement("div");
6825
+ this.containerEl.className = "vz-container";
6826
+ this.shadow.appendChild(this.containerEl);
6827
+ this.renderer = new VzRenderer(this.shadow, this.containerEl, this.state, this.config);
6828
+ this.items = this.parseItems();
6829
+ this.xrManager = new XRManager(
6830
+ this.renderer.getThreeRenderer(),
6831
+ this.state,
6832
+ this.events,
6833
+ this.renderer.getCameraController()
6834
+ );
6835
+ if (!this.config.hideControls) {
6836
+ this.controlsOverlay = new ControlsOverlay(
6837
+ this.shadow,
6838
+ this.containerEl,
6839
+ this.state,
6840
+ this.config,
6841
+ this.events
6842
+ );
6843
+ this.compass = new Compass(this.state, this.renderer);
6844
+ this.controlsOverlay.addControl(this.compass.getElement(), "right");
6845
+ this.vrButton = new VrButton(this.state, () => {
6846
+ this.xrManager?.toggleXR().catch((err) => {
6847
+ this.events.emit("vz-error", { code: "XR_ERROR", message: String(err) });
6848
+ });
6849
+ });
6850
+ this.controlsOverlay.addControl(this.vrButton.getElement(), "right");
6851
+ this.fullscreenButton = new FullscreenButton(this.state, this);
6852
+ this.controlsOverlay.addControl(this.fullscreenButton.getElement(), "right");
6853
+ }
6854
+ this.cleanupFullscreen = onFullscreenChange(() => {
6855
+ this.state.set("fullscreen", isFullscreen());
6856
+ this.renderer.resize();
6857
+ });
6858
+ this.controller = new HubController({
6859
+ items: this.items,
6860
+ events: this.events,
6861
+ onShowItem: (item) => this.playItem(item),
6862
+ onShowLobby: () => this.returnToLobby()
6863
+ });
6864
+ this.panelManager = new HubPanelManager(
6865
+ this.renderer.getScene(),
6866
+ this.renderer.getCamera(),
6867
+ this.containerEl,
6868
+ (index) => this.controller?.select(index)
6869
+ );
6870
+ this.showLobby();
6871
+ this.renderer.start();
6872
+ this.events.emit("vz-ready");
6873
+ }
6874
+ onDisconnected() {
6875
+ this.cleanupFullscreen?.();
6876
+ this.playingEl?.remove();
6877
+ this.playingEl = null;
6878
+ this.compass?.dispose();
6879
+ this.fullscreenButton?.dispose();
6880
+ this.vrButton?.dispose();
6881
+ this.controlsOverlay?.dispose();
6882
+ this.xrManager?.dispose();
6883
+ this.panelManager?.dispose();
6884
+ this.panelManager = null;
6885
+ this.bgTexture?.dispose();
6886
+ this.bgTexture = null;
6887
+ this.renderer?.dispose();
6888
+ }
6889
+ onAttributeChanged() {
6890
+ }
6891
+ onResize() {
6892
+ this.renderer?.resize();
6893
+ }
6894
+ /**
6895
+ * Hub is an Enterprise feature; WebXR is gated separately. On license resolve:
6896
+ * - hub not licensed → lock to a static lobby panorama (panels removed, an
6897
+ * upgrade note shown) — graceful downgrade, the background still renders.
6898
+ * - webxr not licensed → remove the VR button.
6899
+ */
6900
+ onLicenseResolved() {
6901
+ if (resolveFeatureGate("webxr", this.hasFeature("webxr")).action !== "allow") {
6902
+ this.vrButton?.dispose();
6903
+ this.vrButton?.getElement().remove();
6904
+ this.vrButton = null;
6905
+ }
6906
+ if (resolveFeatureGate("hub", this.hasFeature("hub")).action !== "allow") {
6907
+ this.hubLocked = true;
6908
+ this.panelManager?.clear();
6909
+ this.playingEl?.remove();
6910
+ this.playingEl = null;
6911
+ this.showUpgradeOverlay();
6912
+ }
6913
+ }
6914
+ toggleFullscreen() {
6915
+ if (isFullscreen()) exitFullscreen();
6916
+ else requestFullscreen(this);
6917
+ }
6918
+ /** Show the lobby: background panorama + clickable panels (unless locked). */
6919
+ showLobby() {
6920
+ const bg = this.getAttribute("background");
6921
+ if (bg) {
6922
+ const format = parseFormat(this.getAttribute("background-format") ?? "MONO_360") ?? "MONO_360";
6923
+ this.state.set("loading", true);
6924
+ this.loader.load(
6925
+ bg,
6926
+ (texture) => {
6927
+ texture.colorSpace = SRGBColorSpace;
6928
+ texture.minFilter = LinearFilter;
6929
+ this.bgTexture?.dispose();
6930
+ this.bgTexture = texture;
6931
+ this.renderer.setupImage(texture, format);
6932
+ this.state.set("loading", false);
6933
+ },
6934
+ void 0,
6935
+ () => this.state.set("loading", false)
6936
+ );
6937
+ }
6938
+ if (!this.hubLocked) {
6939
+ this.panelManager?.setItems(this.items);
6940
+ }
6941
+ }
6942
+ /** Play a selected item inline via a child vz-video/vz-img + a back control. */
6943
+ playItem(item) {
6944
+ if (this.hubLocked) return;
6945
+ this.panelManager?.clear();
6946
+ const overlay = document.createElement("div");
6947
+ overlay.className = "vz-hub-playing";
6948
+ overlay.style.cssText = "position:absolute;inset:0;z-index:15;background:#000;";
6949
+ const child = document.createElement(isImageSource(item.src) ? "vz-img" : "vz-video");
6950
+ child.setAttribute("src", item.src);
6951
+ child.setAttribute("format", item.format);
6952
+ child.setAttribute("crossorigin", "anonymous");
6953
+ if (isImageSource(item.src) === false) {
6954
+ child.setAttribute("muted", "");
6955
+ child.setAttribute("autoplay", "");
6956
+ }
6957
+ if (this.config.apiKey) child.setAttribute("api-key", this.config.apiKey);
6958
+ if (this.config.apiEndpoint) child.setAttribute("api-endpoint", this.config.apiEndpoint);
6959
+ if (this.config.licenseKey) child.setAttribute("license-key", this.config.licenseKey);
6960
+ child.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;";
6961
+ const back = document.createElement("button");
6962
+ back.className = "vz-hub-back";
6963
+ back.type = "button";
6964
+ back.textContent = "← Lobby";
6965
+ back.style.cssText = "position:absolute;top:12px;left:12px;z-index:20;padding:8px 14px;border:none;border-radius:8px;background:rgba(0,0,0,0.6);color:#fff;font:500 13px system-ui;cursor:pointer;backdrop-filter:blur(8px);";
6966
+ back.addEventListener("click", (e) => {
6967
+ e.stopPropagation();
6968
+ this.controller?.back();
6969
+ });
6970
+ overlay.appendChild(child);
6971
+ overlay.appendChild(back);
6972
+ this.shadow.appendChild(overlay);
6973
+ this.playingEl = overlay;
6974
+ }
6975
+ /** Return to the lobby: tear down the inline player, re-render panels. */
6976
+ returnToLobby() {
6977
+ this.playingEl?.remove();
6978
+ this.playingEl = null;
6979
+ if (!this.hubLocked) {
6980
+ this.panelManager?.setItems(this.items);
6981
+ }
6982
+ }
6983
+ showUpgradeOverlay() {
6984
+ if (this.shadow.querySelector(".vz-hub-gate")) return;
6985
+ const overlay = document.createElement("div");
6986
+ overlay.className = "vz-hub-gate";
6987
+ overlay.style.cssText = "position:absolute;left:0;right:0;bottom:0;padding:10px;text-align:center;z-index:12;background:rgba(0,0,0,0.7);color:#fff;font:500 13px system-ui;";
6988
+ overlay.textContent = "The content hub requires a Vizor Enterprise plan.";
6989
+ this.containerEl.appendChild(overlay);
6990
+ }
6991
+ /** Parse <vz-hub-item> children into a normalized item list. */
6992
+ parseItems() {
6993
+ const raw = [];
6994
+ this.querySelectorAll("vz-hub-item").forEach((el) => {
6995
+ const vzEl = el;
6996
+ if (typeof vzEl.getData === "function") raw.push(vzEl.getData());
6997
+ });
6998
+ return normalizeHubItems(raw);
6999
+ }
7000
+ }
7001
+ function registerHub() {
7002
+ if (typeof customElements === "undefined") return;
7003
+ if (!customElements.get("vz-hub")) {
7004
+ customElements.define("vz-hub", VzHubElement);
7005
+ }
7006
+ }
5691
7007
  function registerAll() {
5692
7008
  if (typeof customElements === "undefined") return;
5693
- registerVideo();
5694
- registerImg();
7009
+ registerAnnotation();
7010
+ registerChapter();
7011
+ registerHubItem();
5695
7012
  registerTourPortal();
5696
7013
  registerTourImg();
7014
+ registerVideo();
7015
+ registerImg();
5697
7016
  registerTour();
5698
7017
  registerCinema();
5699
7018
  registerPlaylist();
5700
7019
  registerLive();
5701
- registerAnnotation();
7020
+ registerHub();
5702
7021
  }
5703
7022
  registerAll();
5704
7023
  export {
@@ -5737,4 +7056,4 @@ export {
5737
7056
  registerTourPortal as y,
5738
7057
  registerVideo as z
5739
7058
  };
5740
- //# sourceMappingURL=register-CwnmiGEs.js.map
7059
+ //# sourceMappingURL=register-BmZt_300.js.map